matchingUsers =
+ session
+ .users()
+ .searchForUserByUserAttributeStream(realm, userAttributeName, incomingIdentifier)
+ .collect(Collectors.toList());
+
+ if (matchingUsers.isEmpty()) {
+ log.infof(
+ "CustomAttributeIdpLinkingAuthenticator: no user found with attribute '%s'='%s',"
+ + " proceeding with next step",
+ userAttributeName, incomingIdentifier);
+ context.attempted();
+ return;
+ }
+
+ if (matchingUsers.size() > 1) {
+ log.errorf(
+ "CustomAttributeIdpLinkingAuthenticator: %d users found with attribute '%s'='%s',"
+ + " failing to prevent ambiguous account linking",
+ matchingUsers.size(), userAttributeName, incomingIdentifier);
+ context.failure(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ return;
+ }
+
+ UserModel existingUser = matchingUsers.get(0);
+ log.infof(
+ "CustomAttributeIdpLinkingAuthenticator: linking IdP identity to user '%s' via"
+ + " attribute '%s'='%s'",
+ existingUser.getUsername(), userAttributeName, incomingIdentifier);
+
+ // Force override Link as we are mapping different users to a single user.
+ AuthenticationSessionModel authSession = context.getAuthenticationSession();
+ authSession.setAuthNote(IdpConfirmOverrideLinkAuthenticator.OVERRIDE_LINK, "true");
+
+ context.setUser(existingUser);
+ context.success();
+ }
+
+ @Override
+ protected void actionImpl(
+ AuthenticationFlowContext context,
+ SerializedBrokeredIdentityContext serializedCtx,
+ BrokeredIdentityContext brokerContext) {
+ // No interactive form is shown by this authenticator.
+ }
+
+ @Override
+ public boolean requiresUser() {
+ return false;
+ }
+
+ @Override
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
+ return true;
+ }
+
+ @Override
+ public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
+
+ @Override
+ public void close() {}
+
+ /**
+ * Extracts the value of a claim from the brokered identity context.
+ *
+ * Well-known fields (email, username, id/sub, firstname, lastname) are resolved through their
+ * dedicated accessors. Any other name is looked up in the mapped user-attribute collection.
+ */
+ public String extractClaimValue(BrokeredIdentityContext brokerContext, String claim) {
+ switch (claim.toLowerCase()) {
+ case "email":
+ return brokerContext.getEmail();
+ case "username":
+ return brokerContext.getUsername();
+ case "id":
+ case "sub":
+ return brokerContext.getId();
+ case "firstname":
+ case "first_name":
+ return brokerContext.getFirstName();
+ case "lastname":
+ case "last_name":
+ return brokerContext.getLastName();
+ default:
+ return brokerContext.getUserAttribute(claim);
+ }
+ }
+}
diff --git a/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorFactory.java b/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorFactory.java
new file mode 100644
index 00000000000..af2ee1d82bc
--- /dev/null
+++ b/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorFactory.java
@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: 2025 Sequent Tech Inc
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package sequent.keycloak.idp_linking_authenticator;
+
+import com.google.auto.service.AutoService;
+import java.util.List;
+import org.keycloak.Config;
+import org.keycloak.authentication.Authenticator;
+import org.keycloak.authentication.AuthenticatorFactory;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+/**
+ * Factory for {@link CustomAttributeIdpLinkingAuthenticator}.
+ *
+ * Exposes two configuration properties:
+ *
+ *
+ * - {@value #CONF_IDP_CLAIM} – the claim/attribute name to read from the incoming IdP identity
+ * (e.g. {@code email}, {@code username}, {@code SAFE_ID}).
+ *
- {@value #CONF_USER_ATTRIBUTE} – the Keycloak user attribute whose values are searched for
+ * the incoming claim value (e.g. {@code linked_idp_identities}).
+ *
+ */
+@AutoService(AuthenticatorFactory.class)
+public class CustomAttributeIdpLinkingAuthenticatorFactory implements AuthenticatorFactory {
+
+ public static final String PROVIDER_ID = "idp-linking-authenticator";
+
+ public static final String CONF_IDP_CLAIM = "idp-claim";
+ public static final String DEFAULT_IDP_CLAIM = "email";
+
+ public static final String CONF_USER_ATTRIBUTE = "user-attribute";
+ public static final String DEFAULT_USER_ATTRIBUTE = "linked_idp_identities";
+
+ private static final CustomAttributeIdpLinkingAuthenticator SINGLETON =
+ new CustomAttributeIdpLinkingAuthenticator();
+
+ private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
+ AuthenticationExecutionModel.Requirement.ALTERNATIVE,
+ AuthenticationExecutionModel.Requirement.REQUIRED,
+ AuthenticationExecutionModel.Requirement.DISABLED,
+ };
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Custom Attribute IdP Identity Linking";
+ }
+
+ @Override
+ public String getHelpText() {
+ return "During the First Broker Login flow, searches for an existing Keycloak user whose"
+ + " multi-value attribute contains the value of a configurable claim from the incoming IdP"
+ + " identity. If exactly one match is found the IdP identity is linked to that user."
+ + " Zero matches pass control to the next step; multiple matches fail the flow to prevent"
+ + " ambiguous account linking.";
+ }
+
+ @Override
+ public String getReferenceCategory() {
+ return "Identity Provider Linking";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return true;
+ }
+
+ @Override
+ public boolean isUserSetupAllowed() {
+ return false;
+ }
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return List.of(
+ new ProviderConfigProperty(
+ CONF_IDP_CLAIM,
+ "IdP Claim",
+ "The claim/attribute to read from the incoming IdP identity (e.g. 'email',"
+ + " 'username', 'id', or a custom mapped attribute such as 'SAFE_ID').",
+ ProviderConfigProperty.STRING_TYPE,
+ DEFAULT_IDP_CLAIM),
+ new ProviderConfigProperty(
+ CONF_USER_ATTRIBUTE,
+ "User Attribute",
+ "The Keycloak user attribute (multi-value) to search for the claim value"
+ + " (e.g. 'linked_idp_identities').",
+ ProviderConfigProperty.STRING_TYPE,
+ DEFAULT_USER_ATTRIBUTE));
+ }
+
+ @Override
+ public Authenticator create(KeycloakSession session) {
+ return SINGLETON;
+ }
+
+ @Override
+ public void init(Config.Scope config) {}
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {}
+
+ @Override
+ public void close() {}
+}
diff --git a/packages/keycloak-extensions/idp-linking-authenticator/src/test/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorTest.java b/packages/keycloak-extensions/idp-linking-authenticator/src/test/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorTest.java
new file mode 100644
index 00000000000..fede04d465f
--- /dev/null
+++ b/packages/keycloak-extensions/idp-linking-authenticator/src/test/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorTest.java
@@ -0,0 +1,309 @@
+// SPDX-FileCopyrightText: 2025 Sequent Tech Inc
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package sequent.keycloak.idp_linking_authenticator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.keycloak.authentication.AuthenticationFlowContext;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.models.AuthenticatorConfigModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserProvider;
+import org.keycloak.sessions.AuthenticationSessionModel;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class CustomAttributeIdpLinkingAuthenticatorTest {
+
+ private CustomAttributeIdpLinkingAuthenticator authenticator;
+
+ @Mock private AuthenticationFlowContext context;
+ @Mock private SerializedBrokeredIdentityContext serializedCtx;
+ @Mock private BrokeredIdentityContext brokerContext;
+ @Mock private AuthenticatorConfigModel authConfig;
+ @Mock private KeycloakSession session;
+ @Mock private RealmModel realm;
+ @Mock private UserProvider userProvider;
+ @Mock private UserModel user;
+ @Mock private AuthenticationSessionModel authSession;
+
+ @BeforeEach
+ void setUp() {
+ authenticator = new CustomAttributeIdpLinkingAuthenticator();
+ }
+
+ // ── Configuration handling ──────────────────────────────────────────────────
+
+ @Test
+ void testAuthenticateImpl_nullConfig_attempts() {
+ when(context.getAuthenticatorConfig()).thenReturn(null);
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ verify(context, never()).success();
+ verify(context, never()).failure(any());
+ }
+
+ @Test
+ void testAuthenticateImpl_nullConfigMap_attempts() {
+ when(context.getAuthenticatorConfig()).thenReturn(authConfig);
+ when(authConfig.getConfig()).thenReturn(null);
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ verify(context, never()).success();
+ }
+
+ // ── Claim extraction ────────────────────────────────────────────────────────
+
+ @Test
+ void testAuthenticateImpl_emptyClaimValue_attempts() {
+ setConfig(
+ CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_IDP_CLAIM,
+ CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_USER_ATTRIBUTE);
+ when(brokerContext.getEmail()).thenReturn("");
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ }
+
+ @Test
+ void testAuthenticateImpl_nullClaimValue_attempts() {
+ setConfig(
+ CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_IDP_CLAIM,
+ CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_USER_ATTRIBUTE);
+ when(brokerContext.getEmail()).thenReturn(null);
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ }
+
+ // ── User lookup results ─────────────────────────────────────────────────────
+
+ @Test
+ void testAuthenticateImpl_noMatchingUser_attempts() {
+ setConfig("email", "linked_idp_identities");
+ when(brokerContext.getEmail()).thenReturn("voter@example.com");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(session.users()).thenReturn(userProvider);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("voter@example.com")))
+ .thenReturn(Stream.empty());
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ verify(context, never()).success();
+ }
+
+ @Test
+ void testAuthenticateImpl_exactlyOneUser_setsUserAndSucceeds() {
+ setConfig("email", "linked_idp_identities");
+ when(brokerContext.getEmail()).thenReturn("voter@example.com");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(context.getAuthenticationSession()).thenReturn(authSession);
+ when(session.users()).thenReturn(userProvider);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("voter@example.com")))
+ .thenReturn(Stream.of(user));
+ when(user.getUsername()).thenReturn("voter1");
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).setUser(user);
+ verify(context).success();
+ verify(context, never()).attempted();
+ verify(context, never()).failure(any());
+ }
+
+ @Test
+ void testAuthenticateImpl_multipleMatchingUsers_fails() {
+ setConfig("email", "linked_idp_identities");
+ when(brokerContext.getEmail()).thenReturn("shared@example.com");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(session.users()).thenReturn(userProvider);
+
+ UserModel user2 = mock(UserModel.class);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("shared@example.com")))
+ .thenReturn(Stream.of(user, user2));
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).failure(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR);
+ verify(context, never()).success();
+ verify(context, never()).attempted();
+ }
+
+ // ── Configurable claim names ────────────────────────────────────────────────
+
+ @Test
+ void testAuthenticateImpl_customClaim_usesUserAttribute() {
+ setConfig("SAFE_ID", "linked_idp_identities");
+ when(brokerContext.getUserAttribute("SAFE_ID")).thenReturn("SAFE-12345");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(context.getAuthenticationSession()).thenReturn(authSession);
+ when(session.users()).thenReturn(userProvider);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("SAFE-12345")))
+ .thenReturn(Stream.of(user));
+ when(user.getUsername()).thenReturn("voter1");
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).setUser(user);
+ verify(context).success();
+ }
+
+ @Test
+ void testAuthenticateImpl_configuredUsernameClaim_usesUsername() {
+ setConfig("username", "linked_idp_identities");
+ when(brokerContext.getUsername()).thenReturn("external_voter");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(session.users()).thenReturn(userProvider);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("external_voter")))
+ .thenReturn(Stream.empty());
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ }
+
+ @Test
+ void testAuthenticateImpl_configuredIdClaim_usesId() {
+ setConfig("id", "linked_idp_identities");
+ when(brokerContext.getId()).thenReturn("external-id-999");
+ when(context.getSession()).thenReturn(session);
+ when(context.getRealm()).thenReturn(realm);
+ when(session.users()).thenReturn(userProvider);
+ when(userProvider.searchForUserByUserAttributeStream(
+ eq(realm), eq("linked_idp_identities"), eq("external-id-999")))
+ .thenReturn(Stream.empty());
+
+ authenticator.authenticateImpl(context, serializedCtx, brokerContext);
+
+ verify(context).attempted();
+ }
+
+ // ── extractClaimValue helper ────────────────────────────────────────────────
+
+ @Test
+ void testExtractClaimValue_email() {
+ when(brokerContext.getEmail()).thenReturn("test@example.com");
+ assertEquals("test@example.com", authenticator.extractClaimValue(brokerContext, "email"));
+ }
+
+ @Test
+ void testExtractClaimValue_username() {
+ when(brokerContext.getUsername()).thenReturn("testuser");
+ assertEquals("testuser", authenticator.extractClaimValue(brokerContext, "username"));
+ }
+
+ @Test
+ void testExtractClaimValue_id() {
+ when(brokerContext.getId()).thenReturn("subject-abc");
+ assertEquals("subject-abc", authenticator.extractClaimValue(brokerContext, "id"));
+ }
+
+ @Test
+ void testExtractClaimValue_sub_aliasForId() {
+ when(brokerContext.getId()).thenReturn("subject-abc");
+ assertEquals("subject-abc", authenticator.extractClaimValue(brokerContext, "sub"));
+ }
+
+ @Test
+ void testExtractClaimValue_firstname() {
+ when(brokerContext.getFirstName()).thenReturn("Alice");
+ assertEquals("Alice", authenticator.extractClaimValue(brokerContext, "firstname"));
+ }
+
+ @Test
+ void testExtractClaimValue_firstNameUnderscore() {
+ when(brokerContext.getFirstName()).thenReturn("Alice");
+ assertEquals("Alice", authenticator.extractClaimValue(brokerContext, "first_name"));
+ }
+
+ @Test
+ void testExtractClaimValue_lastname() {
+ when(brokerContext.getLastName()).thenReturn("Smith");
+ assertEquals("Smith", authenticator.extractClaimValue(brokerContext, "lastname"));
+ }
+
+ @Test
+ void testExtractClaimValue_lastNameUnderscore() {
+ when(brokerContext.getLastName()).thenReturn("Smith");
+ assertEquals("Smith", authenticator.extractClaimValue(brokerContext, "last_name"));
+ }
+
+ @Test
+ void testExtractClaimValue_customAttribute() {
+ when(brokerContext.getUserAttribute("SAFE_ID")).thenReturn("SAFE-999");
+ assertEquals("SAFE-999", authenticator.extractClaimValue(brokerContext, "SAFE_ID"));
+ }
+
+ @Test
+ void testExtractClaimValue_unknownAttribute_returnsNull() {
+ when(brokerContext.getUserAttribute("unknown_claim")).thenReturn(null);
+ assertNull(authenticator.extractClaimValue(brokerContext, "unknown_claim"));
+ }
+
+ // ── Factory metadata ────────────────────────────────────────────────────────
+
+ @Test
+ void testFactory_providerIdAndDisplayType() {
+ CustomAttributeIdpLinkingAuthenticatorFactory factory =
+ new CustomAttributeIdpLinkingAuthenticatorFactory();
+ assertEquals(CustomAttributeIdpLinkingAuthenticatorFactory.PROVIDER_ID, factory.getId());
+ assertEquals("Custom Attribute IdP Identity Linking", factory.getDisplayType());
+ }
+
+ @Test
+ void testFactory_configPropertiesHaveTwoEntries() {
+ CustomAttributeIdpLinkingAuthenticatorFactory factory =
+ new CustomAttributeIdpLinkingAuthenticatorFactory();
+ List props = factory.getConfigProperties();
+ assertEquals(2, props.size());
+ assertEquals(
+ CustomAttributeIdpLinkingAuthenticatorFactory.CONF_IDP_CLAIM, props.get(0).getName());
+ assertEquals(
+ CustomAttributeIdpLinkingAuthenticatorFactory.CONF_USER_ATTRIBUTE, props.get(1).getName());
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────────────
+
+ private void setConfig(String idpClaim, String userAttribute) {
+ Map config = new HashMap<>();
+ config.put(CustomAttributeIdpLinkingAuthenticatorFactory.CONF_IDP_CLAIM, idpClaim);
+ config.put(CustomAttributeIdpLinkingAuthenticatorFactory.CONF_USER_ATTRIBUTE, userAttribute);
+ when(authConfig.getConfig()).thenReturn(config);
+ when(context.getAuthenticatorConfig()).thenReturn(authConfig);
+ }
+}
diff --git a/packages/keycloak-extensions/pom.xml b/packages/keycloak-extensions/pom.xml
index 28f9f623e79..c0abd32a8db 100644
--- a/packages/keycloak-extensions/pom.xml
+++ b/packages/keycloak-extensions/pom.xml
@@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
sequent-theme
custom-event-listener
url-truststore-provider
+ idp-linking-authenticator