Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When an Identity Provider gives a different UserID for an existing Federated Identity, IdentityBrokerService creates a duplicate and fails #20677

Conversation

looorent
Copy link
Contributor

Original issue

This PR closes #9209.

Description

The IdentityBrokerService.afterFirstBrokerLogin method currently assumes that a Federated Identity must be created, even when it already exists. However, this assumption violates the uniqueness constraints and causes the function to fail.

This breaks a unicity constraints and make the function fail. For example, with PostgreSQL (source: #9209):

19:01:30,051 ERROR [org.hibernate.engine.jdbc.spi.SqlExceptionHelper] (default task-9) ERROR: duplicate key value violates unique constraint "constraint_40"
  Detail: Key (identity_provider, user_id)=(<identity provider id>, <user id>) already exists.
19:01:30,059 ERROR [org.keycloak.services.resources.IdentityBrokerService] (default task-9) identityProviderUnexpectedErrorMessage: org.keycloak.models.ModelDuplicateException: javax.persistence.PersistenceException: org.hibernate.exception.ConstraintViolationException: could not execute statement
	at org.keycloak.keycloak-model-jpa@12.0.4//org.keycloak.connections.jpa.PersistenceExceptionConverter.convert(PersistenceExceptionConverter.java:57)
	at org.keycloak.keycloak-model-jpa@12.0.4//org.keycloak.connections.jpa.PersistenceExceptionConverter.invoke(PersistenceExceptionConverter.java:51)
	at javax.persistence.api@2.2.3//com.sun.proxy.$Proxy91.flush(Unknown Source)
	at org.keycloak.keycloak-model-jpa@12.0.4//org.keycloak.models.jpa.JpaUserProvider.addFederatedIdentity(JpaUserProvider.java:181)
	at org.keycloak.keycloak-services@12.0.4//org.keycloak.storage.UserStorageManager.addFederatedIdentity(UserStorageManager.java:500)
	at org.keycloak.keycloak-model-infinispan@12.0.4//org.keycloak.models.cache.infinispan.UserCacheSession.addFederatedIdentity(UserCacheSession.java:813)
	at org.keycloak.keycloak-services@12.0.4//org.keycloak.services.resources.IdentityBrokerService.afterFirstBrokerLogin(IdentityBrokerService.java:713)
	at org.keycloak.keycloak-services@12.0.4//org.keycloak.services.resources.IdentityBrokerService.afterFirstBrokerLogin(IdentityBrokerService.java:665)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:138)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceMethodInvoker.internalInvokeOnTarget(ResourceMethodInvoker.java:543)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTargetAfterFilter(ResourceMethodInvoker.java:432)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceMethodInvoker.lambda$invokeOnTarget$0(ResourceMethodInvoker.java:393)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.interception.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:358)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTarget(ResourceMethodInvoker.java:395)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:364)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceLocatorInvoker.invokeOnTargetObject(ResourceLocatorInvoker.java:150)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.ResourceLocatorInvoker.invoke(ResourceLocatorInvoker.java:104)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:440)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.SynchronousDispatcher.lambda$invoke$4(SynchronousDispatcher.java:229)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.SynchronousDispatcher.lambda$preprocess$0(SynchronousDispatcher.java:135)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.interception.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:358)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.SynchronousDispatcher.preprocess(SynchronousDispatcher.java:138)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:215)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.plugins.server.servlet.ServletContainerDispatcher.service(ServletContainerDispatcher.java:245)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:61)
	at org.jboss.resteasy.resteasy-jaxrs@3.13.2.Final//org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:56)
	at javax.servlet.api@2.0.0.Final//javax.servlet.http.HttpServlet.service(HttpServlet.java:590)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
	at org.keycloak.keycloak-wildfly-extensions@12.0.4//org.keycloak.provider.wildfly.WildFlyRequestFilter.lambda$doFilter$0(WildFlyRequestFilter.java:41)
	at org.keycloak.keycloak-services@12.0.4//org.keycloak.services.filters.AbstractRequestFilter.filter(AbstractRequestFilter.java:43)
	at org.keycloak.keycloak-wildfly-extensions@12.0.4//org.keycloak.provider.wildfly.WildFlyRequestFilter.doFilter(WildFlyRequestFilter.java:39)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:61)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.security.SecurityContextAssociationHandler.handleRequest(SecurityContextAssociationHandler.java:78)
	at io.undertow.core@2.2.2.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:132)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
	at io.undertow.core@2.2.2.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.core@2.2.2.Final//io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
	at io.undertow.core@2.2.2.Final//io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
	at io.undertow.core@2.2.2.Final//io.undertow.security.handlers.NotificationReceiverHandler.handleRequest(NotificationReceiverHandler.java:50)
	at io.undertow.core@2.2.2.Final//io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
	at io.undertow.core@2.2.2.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.security.jacc.JACCContextIdHandler.handleRequest(JACCContextIdHandler.java:61)
	at io.undertow.core@2.2.2.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.deployment.GlobalRequestControllerHandler.handleRequest(GlobalRequestControllerHandler.java:68)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
	at io.undertow.core@2.2.2.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:269)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler.access$100(ServletInitialHandler.java:78)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:133)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:130)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.security.SecurityContextThreadSetupAction.lambda$create$0(SecurityContextThreadSetupAction.java:105)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1530)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1530)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1530)
	at org.wildfly.extension.undertow@21.0.2.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1530)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:249)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler.access$000(ServletInitialHandler.java:78)
	at io.undertow.servlet@2.2.2.Final//io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:99)
	at io.undertow.core@2.2.2.Final//io.undertow.server.Connectors.executeRootHandler(Connectors.java:387)
	at io.undertow.core@2.2.2.Final//io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:841)
	at org.jboss.threads@2.4.0.Final//org.jboss.threads.ContextClassLoaderSavingRunnable.run(ContextClassLoaderSavingRunnable.java:35)
	at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor.safeRun(EnhancedQueueExecutor.java:1990)
	at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor$ThreadBody.doRunTask(EnhancedQueueExecutor.java:1486)
	at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1377)
	at org.jboss.xnio@3.8.2.Final//org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1280)
	at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: javax.persistence.PersistenceException: org.hibernate.exception.ConstraintViolationException: could not execute statement
	at org.hibernate@5.3.20.Final//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:154)
	at org.hibernate@5.3.20.Final//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:181)
	at org.hibernate@5.3.20.Final//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:188)
	at org.hibernate@5.3.20.Final//org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1478)
	at org.hibernate@5.3.20.Final//org.hibernate.internal.SessionImpl.flush(SessionImpl.java:1458)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.keycloak.keycloak-model-jpa@12.0.4//org.keycloak.connections.jpa.PersistenceExceptionConverter.invoke(PersistenceExceptionConverter.java:49)
	... 80 more
Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement
	at org.hibernate@5.3.20.Final//org.hibernate.exception.internal.SQLStateConversionDelegate.convert(SQLStateConversionDelegate.java:112)
	at org.hibernate@5.3.20.Final//org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:42)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:113)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:99)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:178)
	at org.hibernate@5.3.20.Final//org.hibernate.persister.entity.AbstractEntityPersister.insert(AbstractEntityPersister.java:3192)
	at org.hibernate@5.3.20.Final//org.hibernate.persister.entity.AbstractEntityPersister.insert(AbstractEntityPersister.java:3706)
	at org.hibernate@5.3.20.Final//org.hibernate.action.internal.EntityInsertAction.execute(EntityInsertAction.java:90)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:604)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:478)
	at org.hibernate@5.3.20.Final//org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:356)
	at org.hibernate@5.3.20.Final//org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:39)
	at org.hibernate@5.3.20.Final//org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1472)
	... 86 more
Caused by: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "constraint_40"
  Detail: Key (identity_provider, user_id)=(<identity provider id>, <user id>) already exists.
	at org.postgresql.jdbc@42.2.14//org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2532)
	at org.postgresql.jdbc@42.2.14//org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2267)
	at org.postgresql.jdbc@42.2.14//org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:312)
	at org.postgresql.jdbc@42.2.14//org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:448)
	at org.postgresql.jdbc@42.2.14//org.postgresql.jdbc.PgStatement.execute(PgStatement.java:369)
	at org.postgresql.jdbc@42.2.14//org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:153)
	at org.postgresql.jdbc@42.2.14//org.postgresql.jdbc.PgPreparedStatement.executeUpdate(PgPreparedStatement.java:119)
	at org.jboss.ironjacamar.jdbcadapters@1.4.23.Final//org.jboss.jca.adapters.jdbc.WrappedPreparedStatement.executeUpdate(WrappedPreparedStatement.java:537)
	at org.hibernate@5.3.20.Final//org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:175)
	... 94 more

To resolve this problem, I propose modifying the IdentityBrokerService.afterFirstBrokerLogin method to first check if the Federated Identity already exists before attempting to create it.

Example

Let's Keycloak defines a SAML2 identity provider that provides federated identity with Active Directory (AD), and the SubjectID is mapped to the email address claim sent by AD, here's how the situation unfolds:

  • On the user's first login, a Keycloak user account is created with the username hello.world@ad.com. A federated link is automatically associated with the Identity Provider ad, using username=hello.world@ad.comand UserID=hello.world@ad.com.
  • Now, let's consider a situation where the email address in AD slightly changed to Hello.World@ad.com.
  • During the next login of this user, the current implementation attempts to create a new federated identity for the user. However, this operation fails at the database constraint level. The failure occurs because the database enforces uniqueness constraints on the federated identity, and the new UserID Hello.World@ad.com conflicts with the existing federated link.

Implementation

In IdentityBrokerProvider.afterFirstBrokerLogin, we might update the Federated identity when it already exists.
This part of the job is already done in IdentityBrokerProvider.updateFederatedIdentity, depending on the IDP syncMode, which looks good.
However:

  • updateFederatedIdentity is called too late
  • it only compares the username, not the userId

Therefore this PR implements:

  • a check for the existence of a Federated Identity in IdentityBrokerProvider.afterFirstBrokerLogin: this fixes the unicity issue.

  • a comparison the userId in updateFederatedIdentity: it allows the Federated Identity to be updated when the syncMode allows it.

  • In the IdentityBrokerProvider.afterFirstBrokerLogin method, a check is added to verify the existence of a Federated Identity. This resolves the uniqueness issue by preventing the creation of a new federated identity when one already exists.

  • In the IdentityBrokerProvider.updateFederatedIdentity method, the comparison is expanded to include the userId. This ensures that the Federated Identity can be updated when the syncMode allows it.

Versions affected

PR #9209 mentions version 15.0.2.
21.1.1 is still affected.

@sschu
Copy link
Contributor

sschu commented Jul 7, 2023

@pedroigor I know this is normally your text but wouldnt a test be nice for this?

@alechenninger
Copy link
Contributor

@pedroigor We've run into this issue also, but I'm not sure updating the IdP link in place is quite right, either. The problem is the unique constraint is maybe not quite right. It's quite possibly that multiple users coming from the same identity provider link to one user in Keycloak. IdPs do not all share the same cardinality of user identity. You might have two distinct "users" (such as users in different tenants or contexts) in one IdP that federates with Keycloak, where in Keycloak you only have one user.

Updating the link implies the old link should be removed, but that's not necessarily always the case. At least I would suggest this behavior can be controlled with a config option.

Perhaps ideally we were not limited to a single user from one brokered IdP, but I believe changing that cardinality would require breaking changes to a number of APIs. (We are currently working around this by creating separate identity providers in Keycloak with duplicate configuration, so there is still only 1 link per identity provider as far as Keycloak thinks, even though they are all the same external identity provider.)

@pedroigor pedroigor added the missing/tests Tests are missing label Jul 10, 2023
@lexcao
Copy link
Contributor

lexcao commented Sep 1, 2023

@looorent @pedroigor
Same issue here, any update on this?

--
Another option to fix this,
Could we just update the unique index of the table, so that we could support multiple IDP accounts to 1 Keycloak user (like @alechenninger proposed)?

[before]
identity_provider - user_id

[after]
identity_provider - user_id - broker_user_id

Then we need to update the code where we query the federated identity with an additional broker_user_id

WDYT @mposolda

@alechenninger
Copy link
Contributor

I called that out as a way to understand the semantics of the problem. It might not be realistic because changing the uniqueness constraint and cardinality of the relationship would be a breaking change.

@pedroigor pedroigor force-pushed the bug/duplicate_federated_identity_in_identity_broker_service branch from 1349764 to 6f77073 Compare September 11, 2023 18:26
@pedroigor
Copy link
Contributor

@looorent LGTM but we are still missing tests. I'm looking at how we can build a test scenario for it in our test suite.

I think I understand the problem (mainly after reading #9209 (comment)) but not sure yet how to write the test.

@looorent
Copy link
Contributor Author

@pedroigor Thanks. I sadly do not have time at the moment to write this test.

@mposolda
Copy link
Contributor

Having the config option for control this as mentioned by @alechenninger might be good. I can imagine options like:

  • Updating existing federated identity link (like done in this PR)
  • Allow create duplicated federated link (this would be a more major change)
  • Don't allow duplicated link and don't allow updating (pretty much same behaviour like current Keycloak main, but with more friendly error message instead of DB constraint error as we can lookup for federated identity in advance. But I am not 100% sure if we need this option...)

However for the time being, this PR helps with the problem - at least for some deployments. So I agree that we can go with the approach in this PR for now and then see if we need to add support for "Allow create duplicated federated link" as a follow-up (This would be probably a bigger task). WDYT?

Regarding testing of this PR - I think it can be possible. We need to make sure that user, who has existing federatedLink in realm consumer is able to authenticate in first-broker-login. Not 100% sure if we need to do some tweaks in the first-broker-login flow...

@lexcao
Copy link
Contributor

lexcao commented Sep 18, 2023

Hi @mposolda
Sounds great, let me finish the test.

@lexcao
Copy link
Contributor

lexcao commented Sep 19, 2023

Hi I just finished the test.
Anyone can guide me how to edit this PR?
It was access denied when I try to force push to the origin branch.

ERROR: Permission to looorent/keycloak.git denied to lexcao.

Or should I create a new one?

@looorent
Copy link
Contributor Author

@lexcao I have added you as contributor.

@lexcao lexcao force-pushed the bug/duplicate_federated_identity_in_identity_broker_service branch from 6f77073 to 9330ecf Compare September 20, 2023 00:52
@lexcao lexcao requested a review from a team September 20, 2023 00:52
@lexcao lexcao requested review from a team as code owners September 20, 2023 00:52
@ghost ghost added the team/store label Sep 20, 2023
@lexcao
Copy link
Contributor

lexcao commented Sep 20, 2023

Thanks, @looorent
I have pushed the changes.
By the way, I have updated the code to ensure that federatedIdentity.brokerUserId is also updated.

Copy link
Contributor

@pedroigor pedroigor left a comment

Choose a reason for hiding this comment

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

@looorent Sorry for the long delay ....

I've rebased your branch here and run some tests on it.

If you prefer I can also send a new PR and close this one.

@lexcao lexcao force-pushed the bug/duplicate_federated_identity_in_identity_broker_service branch 2 times, most recently from 2a3b2d0 to 0ff16c1 Compare January 8, 2024 14:39
@lexcao
Copy link
Contributor

lexcao commented Jan 8, 2024

Hi @pedroigor
I have updated the PR, maybe we can merge it if the CI is all green.

@pedroigor
Copy link
Contributor

@lexcao Glad to see you're back. Thanks.

…derated Identity, IdentityBrokerService creates a duplicate and fails.

Closes keycloak#9209

Signed-off-by: Lex Cao <lexcao@foxmail.com>

federatedIdentity.setUserId(federatedIdentityModel.getUserId());
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to update the user ID? Why the ID will change during authentication?

I'm wondering if that will make it possible to take over accounts if you manage to somehow update the user ID after authenticating as some other user.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes.
These are possible two different broker users when updating.
I think we should update the ID as well.

If we don't update the ID, the next time login via the same IDP user will continue the first-broker-login flow.
Because here can not find the federated user based on the IDP user ID.

UserModel federatedUser = this.session.users().getUserByFederatedIdentity(this.realmModel, federatedIdentityModel);

I'm wondering if that will make it possible to take over accounts if you manage to somehow update the user ID after authenticating as some other user.

Before update all fields of the broker link, it makes sure the username or userId is matched.
There are two cases when updating:
case 1:

[before]
User A:
 - username: user_a
 - broker_user: 
     - username: user_a
     - user_id: id_1

when the new broker_link comes
(This happens when the broker_user create a new account but use the previous username)
broker_user:
    - username: user_a
    - user_id: id_2

[after]
User A:
 - username: user_a
 - broker_user: 
     - username: user_a
     - user_id: id_2

case 2:

[before]
User A:
 - username: user_a
 - broker_user: 
     - username: whatever
     - user_id: id_1

when the new broker_link comes
(This happens when the broker_user just changed its username)
broker_user:
    - username: user_b
    - user_id: id_1

[after]
User A
 - username: user_a
 - broker_user: 
     - username: user_b
     - user_id: id_1

The case 2 makes sure the ID not changed.
And I am not sure should we handle the case 1 to update the entire broker link.

Please help to check.

Copy link
Contributor

@pedroigor pedroigor Jan 10, 2024

Choose a reason for hiding this comment

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

I see. True, I did not consider this.

My concern here is basically to make sure, for the user, that he is authenticating from a different account at the IdP. Otherwise, we can (not 100% sure) enter a situation where an attacker can somehow trick the user into authenticating at some random IdP and have their account linked to the attacker.

To be safe, perhaps we should introduce a step during the flow to say "Hey, looks like you have an existing account linked to Foo IdP but using a different than the account you are now authenticating. Do you confirm using the new account instead?"

Something like that. I hope it makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@pedroigor From what I recall, the problem arises post authentication via 'IdpConfirmLinkAuthenticator' (which pertains to the screen intended for user display). In my opinion, this issue seems to be accounted for already.

Regarding the scenario you've highlighted, it's plausible only if an attacker gains access to and can manipulate identities within the external IDP (already) linked to the Keycloak user account. It means an attacker cannot trigger this behavior from another random IDP, it has to be the same that is already linked to the keycloak account. In such a high-level compromise, the attacker could effectively impersonate anyone from the external IDP anyhow.

Copy link
Contributor

Choose a reason for hiding this comment

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

To be safe, perhaps we should introduce a step during the flow to say "Hey, looks like you have an existing account linked to Foo IdP but using a different than the account you are now authenticating. Do you confirm using the new account instead?"

@pedroigor
Agree, this is what we added in our production, which verifying the code via the email sent to the Keycloak user.

the problem arises post authentication via 'IdpConfirmLinkAuthenticator' (which pertains to the screen intended for user display).

I think the confirming from IdpConfirmLinkAuthenticator is not safe enough.

Should we add such verification for the case 1 I mentioned above?
At that case, the attacker use the same username login from the IdP, it could override the broker link automatically without the real user noticing.
Or maybe it is difficult get the same username from the IdP for that he needs find the username of the user.

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess that now it would be easier as otherwise we would need to introduce new endpoint(s) to IdentityBrokerService for the whole shebang around showing custom screen with text "Hey, looks like you have an existing account linked to Foo IdP but using a different than the account you are now authenticating. Do you confirm using the new account instead?" if I understand correctly? But when using authenticator SPI, there won't be much customizations needed in the IdentityBrokerService .

Copy link
Contributor

Choose a reason for hiding this comment

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

@mposolda
Sounds a good idea, I prefer adding a new first login flow Authenticator for this case rather than customizing IdentityBrokerService.

Maybe we need split into a new issue to track this and I will create a new PR to implement such feature,
close this one as well.

WDYT @pedroigor

Copy link
Contributor

@mposolda mposolda Jan 15, 2024

Choose a reason for hiding this comment

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

@lexcao +1 from me to your last suggestion

Copy link
Contributor

Choose a reason for hiding this comment

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

Works for me too.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great, @pedroigor @mposolda
I have created a new issue tracking this.
Further discussion will be there.
We can close this as well.

Thank you.
#26201

@mposolda
Copy link
Contributor

I am closing this PR as we have different PR (see related issue #26201 ) with the other approach based on the discussion in this PR.

@mposolda mposolda closed this Jan 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

15.0.2 Identity provider: duplicate key value violates unique constraint
6 participants