Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
id: admin_portal_tutorials_multi_idp_attribute_linking
title: Linking Multiple IdP Identities to a Single User via Custom Attribute
---

<!--
-- SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>
SPDX-License-Identifier: AGPL-3.0-only
-->

## 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).
Comment on lines +64 to +69
Comment on lines +52 to +69
Comment on lines +67 to +69
Comment on lines +67 to +69
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. |
Comment on lines +104 to +112
1 change: 1 addition & 0 deletions packages/Dockerfile.keycloak
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions packages/keycloak-extensions/idp-linking-authenticator/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>

SPDX-License-Identifier: AGPL-3.0-only
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>sequent</groupId>
<artifactId>idp-linking-authenticator</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>26.4.0</keycloak.version>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.version>3.14.0</maven.compiler.version>
<maven.shade.version>3.6.1</maven.shade.version>
<junit.version>5.11.3</junit.version>
<mockito.version>5.14.2</mockito.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>keycloak-spi-bom</artifactId>
<version>${keycloak.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- Keycloak dependencies -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.1.1</version>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- Auto Service -->
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
</dependency>

<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>

<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<finalName>${project.groupId}.${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.version}</version>
<configuration>
<release>${maven.compiler.release}</release>
<annotationProcessorPaths>
<path>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.1.1</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.40</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven.shade.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.reactivestreams:reactive-streams</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.46.1</version>
<configuration>
<java>
<googleJavaFormat>
<version>1.23.0</version>
<style>GOOGLE</style>
</googleJavaFormat>
</java>
</configuration>
</plugin>
</plugins>
</build>

</project>
Loading
Loading