I’m facing an issue with Keycloak while using an external user storage provider. The problem occurs when the same user tries to log in concurrently on multiple devices. Here are the details:
Scenario:
- A user attempts multiple concurrent logins.
- Each login request is authenticated, and the user’s roles are updated.
- This update involves deleting the user’s existing roles and then fetching and assigning new roles from the external TACACS+ user storage.
- The deletion of roles is causing an OptimisticLockException.
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
...
public class MyUserStorageProvider implements UserStorageProvider, UserLookupProvider,
CredentialInputValidator, CredentialInputUpdater
{
private KeycloakSession session;
...
// constructor
public MyUserStorageProvider(KeycloakSession session, ...)
{
this.session = session;
...
}
// implementation of org.keycloak.credential.CredentialInputValidator#isValid
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput)
{
...
UserAuthenticationAuthorizationModel authModel = authenticateUserWithExternalServer(...);
if (authModel.isAuthenticated())
{
// delete all the existing roles of the user
List<RoleModel> roleMappings = user.getRoleMappingsStream().collect(Collectors.toList());
for (RoleModel role : roleMappings)
{
user.deleteRoleMapping(role); // ---> ISSUE HERE
}
// add all the new roles
if (authModel.getRoles() != null && !authModel.getRoles().isEmpty())
{
for (String role : authModel.getRoles())
{
try
{
RoleModel roleModel = UserFederationUtil.getRoleFromString(realm, role);
if (roleModel != null && !user.hasRole(roleModel))
{
user.grantRole(roleModel);
}
}
catch (Exception e)
{
...
}
}
}
}
return authAutorizationModel.isAuthenticated();
}
...
}
Stack trace:
org.keycloak.models.ModelException: javax.persistence.OptimisticLockException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
at [email protected]//org.keycloak.connections.jpa.PersistenceExceptionConverter.convert(PersistenceExceptionConverter.java:61 undefined)
at [email protected]//org.keycloak.connections.jpa.PersistenceExceptionConverter.invoke(PersistenceExceptionConverter.java:51 undefined)
at [email protected]//com.sun.proxy.$Proxy126.flush(Unknown Source)
at [email protected]//org.keycloak.storage.jpa.JpaUserFederatedStorageProvider.deleteRoleMapping(JpaUserFederatedStorageProvider.java:546 undefined)
at [email protected]//org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage.deleteRoleMapping(AbstractUserAdapterFederatedStorage.java:230 undefined)
at [email protected]//org.keycloak.models.cache.infinispan.UserAdapter.deleteRoleMapping(UserAdapter.java:323 undefined)
at com.myproject.user.federation.providers.MyUserStorageProvider.isValid(MyUserStorageProvider.java:136 undefined)
...
Caused by: javax.persistence.OptimisticLockException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
at [email protected]//org.hibernate.internal.ExceptionConverterImpl.wrapStaleStateException(ExceptionConverterImpl.java:238 undefined)
at [email protected]//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java: 93)
at [email protected]//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java: 181)
at [email protected]//org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java: 188)
at [email protected]//org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1478 undefined)
at [email protected]//org.hibernate.internal.SessionImpl.flush(SessionImpl.java:1458 undefined)
at jdk.internal.reflect.GeneratedMethodAccessor428.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43 undefined)
at java.base/java.lang.reflect.Method.invoke(Method.java:566 undefined)
at [email protected]//org.keycloak.connections.jpa.PersistenceExceptionConverter.invoke(PersistenceExceptionConverter.java:49 undefined)
... 92 more
Caused by: org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
at [email protected]//org.hibernate.jdbc.Expectations$BasicExpectation.checkBatched(Expectations.java:67 undefined)
at [email protected]//org.hibernate.jdbc.Expectations$BasicExpectation.verifyOutcome(Expectations.java:54 undefined)
at [email protected]//org.hibernate.engine.jdbc.batch.internal.NonBatchingBatch.addToBatch(NonBatchingBatch.java:46 undefined)
at [email protected]//org.hibernate.persister.entity.AbstractEntityPersister.delete(AbstractEntityPersister.java:3498 undefined)
at [email protected]//org.hibernate.persister.entity.AbstractEntityPersister.delete(AbstractEntityPersister.java:3755 undefined)
at [email protected]//org.hibernate.action.internal.EntityDeleteAction.execute(EntityDeleteAction.java:99 undefined)
at [email protected]//org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:604 undefined)
at [email protected]//org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:478 undefined)
at [email protected]//org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:356 undefined)
at [email protected]//org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:39 undefined)
at [email protected]//org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1472 undefined)
... 97 more
From this Stack Overflow answer, I understand that an optimistic locking exception occurs when another transaction has committed changes to the entities being updated or deleted. I presumed that other threads calling the same isValid(...)
method were modifying the UserModel
object concurrently.
To address this, I surrounded the deletion and role assignment logic within a synchronized block. However, this did not resolve the issue. It seems that the session is not immediately flushed, resulting in commits not occurring sequentially, even with synchronization.
As a workaround, I implemented a lock mechanism before modifying the user roles. I release the lock in the close()
method of Keycloak Provider (org.keycloak.provider.Provider.close()
), which is invoked after the user update is flushed to the database. This ensures that only one thread can update the user’s roles at a time and that the user resource in the DB is updated before the next thread deletes the user roles. However, I’m concerned that this might cause side effects and believe there might be a better solution to update the roles.
Has anyone faced a similar issue or can suggest a more robust solution to handle concurrent logins and role updates in Keycloak with an external user storage? Any help or guidance would be greatly appreciated.