From 7c177db15e9697a66496d48e437d9c90bca8a429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Robles?= Date: Wed, 6 May 2026 18:16:34 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Keycloak=20-=20Map=20multiple=20IdP?= =?UTF-8?q?=20users=20to=20a=20single=20user=20via=20custom=20multi-value?= =?UTF-8?q?=20attribute=20(#2633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent issue: https://github.com/sequentech/meta/issues/11932 - [x] Create new Maven module `idp-linking-authenticator` with pom.xml - [x] Implement `CustomAttributeIdpLinkingAuthenticator` extending `AbstractIdpAuthenticator` - [x] Implement `CustomAttributeIdpLinkingAuthenticatorFactory` with configurable parameters - [x] Write 22 unit tests covering all scenarios (zero/one/multiple matches, all claim types, edge cases) - [x] Update parent `pom.xml` to include new module - [x] Update `Dockerfile.keycloak` to copy new JAR - [x] Add Docusaurus documentation for the feature - [x] Fix: shorten PROVIDER_ID from `custom-attribute-idp-linking-authenticator` (42 chars) to `idp-linking-authenticator` (25 chars) — Keycloak's `AUTHENTICATION_EXECUTION.AUTHENTICATOR` column is `character varying(36)` --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: xalsina-sequent <173030604+xalsina-sequent@users.noreply.github.com> Co-authored-by: Xavier Alsina --- ...l_tutorials_multi-idp-attribute-linking.md | 112 +++++++ packages/Dockerfile.keycloak | 1 + .../idp-linking-authenticator/pom.xml | 168 ++++++++++ ...ustomAttributeIdpLinkingAuthenticator.java | 170 ++++++++++ ...tributeIdpLinkingAuthenticatorFactory.java | 120 +++++++ ...mAttributeIdpLinkingAuthenticatorTest.java | 309 ++++++++++++++++++ packages/keycloak-extensions/pom.xml | 1 + 7 files changed, 881 insertions(+) create mode 100644 docs/docusaurus/docs/admin_portal/03-Tutorials/100-admin_portal_tutorials_multi-idp-attribute-linking.md create mode 100644 packages/keycloak-extensions/idp-linking-authenticator/pom.xml create mode 100644 packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticator.java create mode 100644 packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorFactory.java create mode 100644 packages/keycloak-extensions/idp-linking-authenticator/src/test/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticatorTest.java diff --git a/docs/docusaurus/docs/admin_portal/03-Tutorials/100-admin_portal_tutorials_multi-idp-attribute-linking.md b/docs/docusaurus/docs/admin_portal/03-Tutorials/100-admin_portal_tutorials_multi-idp-attribute-linking.md new file mode 100644 index 00000000000..8d4bb8ca1ff --- /dev/null +++ b/docs/docusaurus/docs/admin_portal/03-Tutorials/100-admin_portal_tutorials_multi-idp-attribute-linking.md @@ -0,0 +1,112 @@ +--- +id: admin_portal_tutorials_multi_idp_attribute_linking +title: Linking Multiple IdP Identities to a Single User via Custom Attribute +--- + + + +## Overview + +By default, Keycloak's identity brokering links an external Identity Provider (IdP) user to a +Keycloak user on a 1-to-1 basis, usually by matching `email` or `username`. This tutorial +explains how to configure the **Custom Attribute IdP Identity Linking** authenticator so that +multiple IdP identities (e.g., different emails or subject IDs from the same or different IdPs) +can be mapped to a single Keycloak user. + +The authenticator reads a configurable claim from the incoming IdP identity and searches for a +Keycloak user whose **multi-value custom attribute** contains that value. When exactly one match +is found the new IdP identity is linked to the existing user automatically. + +--- + +## Prerequisites + +- Access to the Keycloak Admin Console. +- The `sequent.idp-linking-authenticator.jar` extension deployed in Keycloak's `providers/` + directory (included in the Sequent Keycloak Docker image by default). +- An existing External Identity Provider configured in the target realm. + +--- + +## Step 1 – Create the Custom User Attribute + +The authenticator searches for users by a Keycloak user-profile attribute. You must create this +attribute before configuring the flow. + +1. In the Keycloak Admin Console, select the realm you want to configure. +2. Navigate to **Realm settings** → **User profile** → **Create attribute**. +3. Set the **Name** to `linked_idp_identities` (or any name you will use in the authenticator + configuration). +4. Enable the attribute **multi-valued** (do not restrict it to a single value). +5. Optionally restrict read/write permissions so only administrators can manage it. +6. Click **Save**. + +> **Tip:** After creating the attribute you can pre-populate it for existing users via the Admin +> Console (**Users** → select user → **Attributes** tab) or via the Admin REST API. + +--- + +## Step 2 – Create the First Broker Login Flow + +In this step we will create `sequent first broker login multivalue` flow directly. + +1. Navigate to **Authentication** → **Flows**. +2. Click on create flow +3. Give the new flow a descriptive name such as `sequent first broker login multivalue`. + +--- + +## Step 3 – Add the Custom Authenticator to the Flow + +1. Open the duplicnewated flow. +2. Click **Add step** inside the appropriate sub-flow. +3. Search for **Custom Attribute IdP Identity Linking** and add it. +4. Set the requirement to **REQUIRED** if you want the flow to + fail when no match is found or **ALTERNATIVE** (the authenticator will call `attempted()` when no + matching user is found, allowing the next step to run, if you fant to create a user if not found for example). +5. Click **⚙ Config** (gear icon) next to the new step to configure it: + + | Parameter | Description | Default | + |---|---|---| + | **IdP Claim** | Claim/attribute name to read from the incoming IdP identity. Use well-known names (`email`, `username`, `id`/`sub`, `firstname`, `lastname`) or a custom mapped attribute (e.g., `SAFE_ID`). | `email` | + | **User Attribute** | Keycloak user attribute (multi-value) to search for the claim value. | `linked_idp_identities` | + +6. Click **Save**. + +--- + +## Step 4 – Bind the New Flow to the Identity Provider + +1. Navigate to **Identity Providers** and select the IdP you want to configure. +2. In the **First Login Flow** (or **First Broker Login Flow**) dropdown, select the duplicated + flow you created in Step 2. +3. Click **Save**. + +--- + +## Step 5 – Map the Custom Claim from the IdP Token + +If the claim you want to use (e.g., `SAFE_ID`) is not a standard OIDC/SAML field, add an +attribute mapper on the IdP: + +1. In the IdP configuration, open the **Mappers** tab. +2. Click **Add mapper**. +3. Set **Mapper type** to **Attribute Importer** (for OIDC) or **SAML Attribute** (for SAML). +4. Map the IdP claim name to the Keycloak attribute name that matches what you set in + **IdP Claim** (e.g., `SAFE_ID`). +5. Click **Save**. + +--- + +## Behavior Summary + +| Scenario | Authenticator action | +|---|---| +| Configuration is missing | Passes to the next step (`attempted`). | +| The IdP claim is empty or absent | Passes to the next step (`attempted`). | +| No user found with the attribute value | Passes to the next step (`attempted`). | +| Exactly one user found | Links the IdP identity to that user and succeeds. | +| More than one user found | Fails the flow with `IDENTITY_PROVIDER_ERROR` to prevent ambiguous linking. | diff --git a/packages/Dockerfile.keycloak b/packages/Dockerfile.keycloak index 07d944932b5..74b01720826 100644 --- a/packages/Dockerfile.keycloak +++ b/packages/Dockerfile.keycloak @@ -53,6 +53,7 @@ COPY --chown=keycloak:keycloak \ /build/keycloak-extensions/sequent-theme/target/sequent.sequent-theme.jar \ /build/keycloak-extensions/custom-event-listener/target/sequent.custom-event-listener.jar \ /build/keycloak-extensions/url-truststore-provider/target/sequent.url-truststore-provider.jar \ + /build/keycloak-extensions/idp-linking-authenticator/target/sequent.idp-linking-authenticator.jar \ /opt/keycloak/providers/ RUN /opt/keycloak/bin/kc.sh build diff --git a/packages/keycloak-extensions/idp-linking-authenticator/pom.xml b/packages/keycloak-extensions/idp-linking-authenticator/pom.xml new file mode 100644 index 00000000000..29a74166c57 --- /dev/null +++ b/packages/keycloak-extensions/idp-linking-authenticator/pom.xml @@ -0,0 +1,168 @@ + + + + 4.0.0 + + sequent + idp-linking-authenticator + 1.0-SNAPSHOT + + + UTF-8 + 26.4.0 + 17 + 3.14.0 + 3.6.1 + 5.11.3 + 5.14.2 + + + + + + com.github.dasniko + keycloak-spi-bom + ${keycloak.version} + pom + import + + + + + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + ${keycloak.version} + + + jakarta.validation + jakarta.validation-api + 3.1.1 + + + + + org.projectlombok + lombok + + + + + com.google.auto.service + auto-service + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + ${project.groupId}.${project.artifactId} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${maven.compiler.release} + + + com.google.auto.service + auto-service + 1.1.1 + + + org.projectlombok + lombok + 1.18.40 + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven.shade.version} + + + package + + shade + + + + + org.reactivestreams:reactive-streams + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + + 1.23.0 + + + + + + + + + diff --git a/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticator.java b/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticator.java new file mode 100644 index 00000000000..c565ea08583 --- /dev/null +++ b/packages/keycloak-extensions/idp-linking-authenticator/src/main/java/sequent/keycloak/idp_linking_authenticator/CustomAttributeIdpLinkingAuthenticator.java @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.idp_linking_authenticator; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.IdpConfirmOverrideLinkAuthenticator; +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.sessions.AuthenticationSessionModel; + +/** + * First Broker Login authenticator that links an incoming IdP identity to an existing Keycloak user + * by matching a configurable IdP claim against a configurable multi-value user attribute. + * + *

Logic: + * + *

    + *
  • Exactly one match → link the identity to the found user and succeed. + *
  • Zero matches → attempt (pass control to the next step in the flow). + *
  • Multiple matches → fail with an {@link AuthenticationFlowError#IDENTITY_PROVIDER_ERROR} to + * prevent ambiguous account linking. + *
+ */ +@JBossLog +public class CustomAttributeIdpLinkingAuthenticator extends AbstractIdpAuthenticator { + + @Override + protected void authenticateImpl( + AuthenticationFlowContext context, + SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + + AuthenticatorConfigModel authConfig = context.getAuthenticatorConfig(); + if (authConfig == null || authConfig.getConfig() == null) { + log.warn( + "CustomAttributeIdpLinkingAuthenticator: no configuration found, proceeding with next" + + " step"); + context.attempted(); + return; + } + + String idpClaim = + authConfig + .getConfig() + .getOrDefault( + CustomAttributeIdpLinkingAuthenticatorFactory.CONF_IDP_CLAIM, + CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_IDP_CLAIM); + String userAttributeName = + authConfig + .getConfig() + .getOrDefault( + CustomAttributeIdpLinkingAuthenticatorFactory.CONF_USER_ATTRIBUTE, + CustomAttributeIdpLinkingAuthenticatorFactory.DEFAULT_USER_ATTRIBUTE); + + log.debugf( + "CustomAttributeIdpLinkingAuthenticator: brokerContext.attributes=%s", + brokerContext.getAttributes()); + + String incomingIdentifier = extractClaimValue(brokerContext, idpClaim); + if (incomingIdentifier == null || incomingIdentifier.isEmpty()) { + log.warnf( + "CustomAttributeIdpLinkingAuthenticator: no value found for IdP claim '%s', proceeding" + + " with next step", + idpClaim); + context.attempted(); + return; + } + + RealmModel realm = context.getRealm(); + KeycloakSession session = context.getSession(); + + List 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