Skip to content

✨ Keycloak - Map multiple IdP users to a single user via custom multi-value attribute#2598

Merged
Findeton merged 8 commits into
mainfrom
copilot/map-multiple-idp-users
May 6, 2026
Merged

✨ Keycloak - Map multiple IdP users to a single user via custom multi-value attribute#2598
Findeton merged 8 commits into
mainfrom
copilot/map-multiple-idp-users

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 27, 2026

Parent issue: https://github.com/sequentech/meta/issues/11932

  • Create new Maven module idp-linking-authenticator with pom.xml
  • Implement CustomAttributeIdpLinkingAuthenticator extending AbstractIdpAuthenticator
  • Implement CustomAttributeIdpLinkingAuthenticatorFactory with configurable parameters
  • Write 22 unit tests covering all scenarios (zero/one/multiple matches, all claim types, edge cases)
  • Update parent pom.xml to include new module
  • Update Dockerfile.keycloak to copy new JAR
  • Add Docusaurus documentation for the feature
  • 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)

Copilot AI and others added 2 commits April 27, 2026 16:08
…ti-IdP user linking

Agent-Logs-Url: https://github.com/sequentech/step/sessions/5cef1d48-5e8a-40c9-9642-9434ff744db5

Co-authored-by: xalsina-sequent <173030604+xalsina-sequent@users.noreply.github.com>
Agent-Logs-Url: https://github.com/sequentech/step/sessions/5cef1d48-5e8a-40c9-9642-9434ff744db5

Co-authored-by: xalsina-sequent <173030604+xalsina-sequent@users.noreply.github.com>
Copilot AI changed the title [WIP] Add feature to map multiple IdP users to a single Keycloak user feat(keycloak): Custom Attribute IdP Identity Linking authenticator for First Broker Login Apr 27, 2026
Copilot AI requested a review from xalsina-sequent April 27, 2026 16:12
@Findeton Findeton changed the title feat(keycloak): Custom Attribute IdP Identity Linking authenticator for First Broker Login ✨ Keycloak - Map multiple IdP users to a single user via custom multi-value attribute Apr 28, 2026
@github-actions
Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement (CLA).

To sign the CLA, please comment on this PR with:
I have read the CLA Document and I hereby sign the CLA

This is a one-time requirement. Once you have signed, all future contributions will be covered.

You can read the full CLA document here: https://github.com/sequentech/step/blob/main/.github/cla/CLA.md


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 29, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://docs.sequentech.io/docusaurus/pr-preview/pr-2598/

Built to branch doc-previews at 2026-05-04 14:29 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@xalsina-sequent
Copy link
Copy Markdown
Contributor

@copilot There's an error when adding the Authentication to a flow.

keycloak | 2026-04-29 11:11:17,463 INFO [org.hibernate.orm.jdbc.batch] (executor-thread-7) HHH100503: On release of batch it still contained JDBC statements
keycloak | 2026-04-29 11:11:17,467 WARN [org.hibernate.orm.jdbc.error] (executor-thread-7) HHH000247: ErrorCode: 0, SQLState: 22001
keycloak | 2026-04-29 11:11:17,468 WARN [org.hibernate.orm.jdbc.error] (executor-thread-7) Batch entry 0 insert into public.AUTHENTICATION_EXECUTION (AUTHENTICATOR,AUTH_CONFIG,AUTHENTICATOR_FLOW,AUTH_FLOW_ID,FLOW_ID,PRIORITY,REALM_ID,REQUIREMENT,ID) values (('custom-attribute-idp-linking-authenticator'),(NULL),('FALSE'::boolean),(NULL),('218c4223-bcb2-44d0-b3b4-0a6fa700f6d8'),('0'::int4),('5aa2e3b3-e0f2-4bee-a80f-08556826970b'),('3'::int2),('9ec60049-f5b5-4c13-8f3b-be54510094a9')) was aborted: ERROR: value too long for type character varying(36) Call getNextException to see other errors in the batch.
keycloak | 2026-04-29 11:11:17,468 WARN [org.hibernate.orm.jdbc.error] (executor-thread-7) HHH000247: ErrorCode: 0, SQLState: 22001
keycloak | 2026-04-29 11:11:17,468 WARN [org.hibernate.orm.jdbc.error] (executor-thread-7) Batch entry 0 insert into public.AUTHENTICATION_EXECUTION (AUTHENTICATOR,AUTH_CONFIG,AUTHENTICATOR_FLOW,AUTH_FLOW_ID,FLOW_ID,PRIORITY,REALM_ID,REQUIREMENT,ID) values (('custom-attribute-idp-linking-authenticator'),(NULL),('FALSE'::boolean),(NULL),('218c4223-bcb2-44d0-b3b4-0a6fa700f6d8'),('0'::int4),('5aa2e3b3-e0f2-4bee-a80f-08556826970b'),('3'::int2),('9ec60049-f5b5-4c13-8f3b-be54510094a9')) was aborted: ERROR: value too long for type character varying(36) Call getNextException to see other errors in the batch.
keycloak | 2026-04-29 11:11:17,485 ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-7) Uncaught server error: org.keycloak.models.ModelException: Database operation failed
keycloak | at org.keycloak.connections.jpa.support.EntityManagerProxy.convert(EntityManagerProxy.java:150)
keycloak | at org.keycloak.connections.jpa.PersistenceExceptionConverter.convert(PersistenceExceptionConverter.java:30)
keycloak | at org.keycloak.connections.jpa.JpaExceptionConverter.convert(JpaExceptionConverter.java:31)
keycloak | at org.keycloak.transaction.JtaTransactionWrapper.lambda$handleException$0(JtaTransactionWrapper.java:77)
keycloak | at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
keycloak | at java.base/java.util.HashMap$ValueSpliterator.forEachRemaining(HashMap.java:1787)
keycloak | at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
keycloak | at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
keycloak | at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
keycloak | at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
keycloak | at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
keycloak | at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
keycloak | at org.keycloak.transaction.JtaTransactionWrapper.handleException(JtaTransactionWrapper.java:79)
keycloak | at org.keycloak.transaction.JtaTransactionWrapper.commit(JtaTransactionWrapper.java:104)
keycloak | at org.keycloak.services.DefaultKeycloakTransactionManager.lambda$commitWithTracing$1(DefaultKeycloakTransactionManager.java:170)
keycloak | at org.keycloak.tracing.NoopTracingProvider.trace(NoopTracingProvider.java:59)
keycloak | at org.keycloak.tracing.NoopTracingProvider.trace(NoopTracingProvider.java:69)
keycloak | at org.keycloak.services.DefaultKeycloakTransactionManager.commitWithTracing(DefaultKeycloakTransactionManager.java:169)
keycloak | at org.keycloak.services.DefaultKeycloakTransactionManager.lambda$commit$0(DefaultKeycloakTransactionManager.java:136)
keycloak | at org.keycloak.tracing.NoopTracingProvider.trace(NoopTracingProvider.java:59)
keycloak | at org.keycloak.tracing.NoopTracingProvider.trace(NoopTracingProvider.java:69)
keycloak | at org.keycloak.services.DefaultKeycloakTransactionManager.commit(DefaultKeycloakTransactionManager.java:121)
keycloak | at org.keycloak.services.DefaultKeycloakSession.closeTransactionManager(DefaultKeycloakSession.java:392)
keycloak | at org.keycloak.services.DefaultKeycloakSession.close(DefaultKeycloakSession.java:357)
keycloak | at org.keycloak.models.KeycloakBeanProducer_ProducerMethod_getKeycloakSession_XoSEUTXOsE3bpqXlGMAykCiECUM_ClientProxy.close(Unknown Source)
keycloak | at org.keycloak.quarkus.runtime.transaction.TransactionalSessionHandler.close(TransactionalSessionHandler.java:60)
keycloak | at org.keycloak.quarkus.runtime.integration.jaxrs.CloseSessionFilter.closeSession(CloseSessionFilter.java:67)
keycloak | at org.keycloak.quarkus.runtime.integration.jaxrs.CloseSessionFilter.filter(CloseSessionFilter.java:63)
keycloak | at org.jboss.resteasy.reactive.server.handlers.ResourceResponseFilterHandler.handle(ResourceResponseFilterHandler.java:25)
keycloak | at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:192)
keycloak | at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)
keycloak | at io.quarkus.vertx.core.runtime.VertxCoreRecorder$15.runWith(VertxCoreRecorder.java:645)
keycloak | at org.jboss.threads.EnhancedQueueExecutor$Task.doRunWith(EnhancedQueueExecutor.java:2651)
keycloak | at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2630)
keycloak | at org.jboss.threads.EnhancedQueueExecutor.runThreadBody(EnhancedQueueExecutor.java:1622)
keycloak | at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1589)
keycloak | at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:11)
keycloak | at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:11)
keycloak | at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
keycloak | at java.base/java.lang.Thread.run(Thread.java:1583)
keycloak | Caused by: java.sql.BatchUpdateException: Batch entry 0 insert into public.AUTHENTICATION_EXECUTION (AUTHENTICATOR,AUTH_CONFIG,AUTHENTICATOR_FLOW,AUTH_FLOW_ID,FLOW_ID,PRIORITY,REALM_ID,REQUIREMENT,ID) values (('custom-attribute-idp-linking-authenticator'),(NULL),('FALSE'::boolean),(NULL),('218c4223-bcb2-44d0-b3b4-0a6fa700f6d8'),('0'::int4),('5aa2e3b3-e0f2-4bee-a80f-08556826970b'),('3'::int2),('9ec60049-f5b5-4c13-8f3b-be54510094a9')) was aborted: ERROR: value too long for type character varying(36) Call getNextException to see other errors in the batch.
keycloak | at org.postgresql.jdbc.BatchResultHandler.handleError(BatchResultHandler.java:165)
keycloak | at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2422)
keycloak | at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:580)
keycloak | at org.postgresql.jdbc.PgStatement.internalExecuteBatch(PgStatement.java:886)
keycloak | at org.postgresql.jdbc.PgStatement.executeBatch(PgStatement.java:910)
keycloak | at org.postgresql.jdbc.PgPreparedStatement.executeBatch(PgPreparedStatement.java:1778)
keycloak | at io.agroal.pool.wrapper.StatementWrapper.executeBatch(StatementWrapper.java:342)
keycloak | at org.hibernate.engine.jdbc.batch.internal.BatchImpl.lambda$performExecution$1(BatchImpl.java:264)
keycloak | at org.hibernate.engine.jdbc.mutation.internal.PreparedStatementGroupSingleTable.forEachStatement(PreparedStatementGroupSingleTable.java:63)
keycloak | at org.hibernate.engine.jdbc.batch.internal.BatchImpl.performExecution(BatchImpl.java:253)
keycloak | at org.hibernate.engine.jdbc.batch.internal.BatchImpl.execute(BatchImpl.java:232)
keycloak | at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.executeBatch(JdbcCoordinatorImpl.java:191)
keycloak | at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:676)
keycloak | at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:513)
keycloak | at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:378)
keycloak | at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:39)
keycloak | at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:140)
keycloak | at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1447)
keycloak | at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:488)
keycloak | at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2325)
keycloak | at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2033)
keycloak | at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:394)
keycloak | at org.hibernate.resource.transaction.backend.jta.internal.JtaTransactionCoordinatorImpl.beforeCompletion(JtaTransactionCoordinatorImpl.java:328)
keycloak | at org.hibernate.resource.transaction.backend.jta.internal.synchronization.SynchronizationCallbackCoordinatorNonTrackingImpl.beforeCompletion(SynchronizationCallbackCoordinatorNonTrackingImpl.java:39)
keycloak | at org.hibernate.resource.transaction.backend.jta.internal.synchronization.RegisteredSynchronization.beforeCompletion(RegisteredSynchronization.java:33)
keycloak | at com.arjuna.ats.internal.jta.resources.arjunacore.SynchronizationImple.beforeCompletion(SynchronizationImple.java:52)
keycloak | at com.arjuna.ats.arjuna.coordinator.TwoPhaseCoordinator.beforeCompletion(TwoPhaseCoordinator.java:348)
keycloak | at com.arjuna.ats.arjuna.coordinator.TwoPhaseCoordinator.end(TwoPhaseCoordinator.java:66)
keycloak | at com.arjuna.ats.arjuna.AtomicAction.commit(AtomicAction.java:135)
keycloak | at com.arjuna.ats.internal.jta.transaction.arjunacore.TransactionImple.commitAndDisassociate(TransactionImple.java:1307)
keycloak | at com.arjuna.ats.internal.jta.transaction.arjunacore.BaseTransaction.commit(BaseTransaction.java:104)
keycloak | at io.quarkus.narayana.jta.runtime.NotifyingTransactionManager.commit(NotifyingTransactionManager.java:70)
keycloak | at org.keycloak.transaction.JtaTransactionWrapper.commit(JtaTransactionWrapper.java:102)
keycloak | ... 26 more
keycloak | Caused by: org.postgresql.util.PSQLException: ERROR: value too long for type character varying(36)
keycloak | at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2734)
keycloak | at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2421)
keycloak | ... 57 more

Agent-Logs-Url: https://github.com/sequentech/step/sessions/2f7aa4a3-f3f4-48ad-bac0-8283baa2f90c

Co-authored-by: xalsina-sequent <173030604+xalsina-sequent@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot There's an error when adding the Authentication to a flow.

keycloak | 2026-04-29 11:11:17,463 INFO [org.hibernate.orm.jdbc.batch] (executor-thread-7) HHH100503: On release of batch it stil...

Fixed in c8e05d8. The PROVIDER_ID custom-attribute-idp-linking-authenticator was 42 characters, exceeding the 36-character varchar(36) limit on Keycloak's AUTHENTICATION_EXECUTION.AUTHENTICATOR column. Shortened to idp-linking-authenticator (25 chars).

@xalsina-sequent xalsina-sequent marked this pull request as ready for review May 4, 2026 15:37
Copilot AI review requested due to automatic review settings May 4, 2026 15:38
@Findeton Findeton requested review from Copilot and removed request for Copilot May 6, 2026 23:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Comment on lines +67 to +68
"CustomAttributeIdpLinkingAuthenticator: brokerContext.attributes=%s",
brokerContext.getAttributes());
List<UserModel> matchingUsers =
session
.users()
.searchForUserByUserAttributeStream(realm, userAttributeName, incomingIdentifier)
Comment on lines +89 to +103
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);
Comment on lines +64 to +69
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 +52 to +69
## 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 +66 to +68
log.debugf(
"CustomAttributeIdpLinkingAuthenticator: brokerContext.attributes=%s",
brokerContext.getAttributes());
Comment on lines +89 to +94
if (matchingUsers.isEmpty()) {
log.infof(
"CustomAttributeIdpLinkingAuthenticator: no user found with attribute '%s'='%s',"
+ " proceeding with next step",
userAttributeName, incomingIdentifier);
context.attempted();

## Step 3 – Add the Custom Authenticator to the Flow

1. Open the duplicnewated 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 +67 to +69
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).
@Findeton Findeton self-requested a review May 6, 2026 23:13
Copy link
Copy Markdown
Contributor

@Findeton Findeton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved but needs another pass.

@Findeton Findeton merged commit dd5e49c into main May 6, 2026
27 of 29 checks passed
@Findeton Findeton deleted the copilot/map-multiple-idp-users branch May 6, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants