diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java index 063e947d8cc..85c868da4e6 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticatedClientSessionAdapter.java @@ -18,6 +18,7 @@ package org.keycloak.models.sessions.infinispan; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import org.keycloak.common.util.Time; @@ -28,8 +29,6 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.changes.ClientSessionUpdateTask; -import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; -import org.keycloak.models.sessions.infinispan.changes.SessionsChangelogBasedTransaction; import org.keycloak.models.sessions.infinispan.changes.Tasks; import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey; @@ -42,31 +41,28 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes private final KeycloakSession kcSession; private final AuthenticatedClientSessionEntity entity; private final ClientModel client; - private final SessionsChangelogBasedTransaction clientSessionUpdateTx; + private final ClientSessionManager clientSessionManager; private UserSessionModel userSession; private final boolean offline; private final EmbeddedClientSessionKey cacheKey; public AuthenticatedClientSessionAdapter(KeycloakSession kcSession, AuthenticatedClientSessionEntity entity, ClientModel client, UserSessionModel userSession, - SessionsChangelogBasedTransaction clientSessionUpdateTx, + ClientSessionManager clientSessionManager, EmbeddedClientSessionKey cacheKey, boolean offline) { - if (userSession == null) { - throw new NullPointerException("userSession must not be null"); - } + this.userSession = Objects.requireNonNull(userSession, "userSession must not be null"); this.kcSession = kcSession; this.entity = entity; - this.userSession = userSession; this.client = client; - this.clientSessionUpdateTx = clientSessionUpdateTx; + this.clientSessionManager = clientSessionManager; this.offline = offline; this.cacheKey = cacheKey; } private void update(ClientSessionUpdateTask task) { - clientSessionUpdateTx.addTask(cacheKey, task); + clientSessionManager.addChange(cacheKey, task); } /** @@ -84,9 +80,7 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes // as nonexistent in org.keycloak.models.sessions.infinispan.UserSessionAdapter.getAuthenticatedClientSessions() this.userSession = null; - SessionUpdateTask removeTask = Tasks.removeSync(offline); - - clientSessionUpdateTx.addTask(cacheKey, removeTask); + clientSessionManager.addChange(cacheKey, Tasks.removeSync(offline)); } @Override @@ -288,7 +282,7 @@ public class AuthenticatedClientSessionAdapter implements AuthenticatedClientSes } }; - update(task); + clientSessionManager.restartEntity(cacheKey, task); } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionManager.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionManager.java new file mode 100644 index 00000000000..1e228577eea --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionManager.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.models.sessions.infinispan; + +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.sessions.infinispan.changes.PersistentSessionUpdateTask; +import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity; +import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey; + +/** + * Manages transactional context for {@link AuthenticatedClientSessionModel} changes. + *

+ * It collects all modifications (the changelog) within the current transaction and applies them to the database only + * upon a successful commit. + */ +public interface ClientSessionManager { + + /** + * Adds a update task to the changelog for a specific client session. + *

+ * When the transaction commits, this task will apply its changes to the persisted + * {@link AuthenticatedClientSessionEntity}, effectively updating the corresponding + * {@link AuthenticatedClientSessionModel}. Multiple {@code addChange} calls for the same session are accumulated + * (merged). + * + * @param key The identifier for the target client session. + * @param task The operation containing the changes to apply to the persisted entity. + * @throws NullPointerException if {@code key} or {@code task} is {@code null}. + */ + void addChange(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task); + + /** + * Resets and replaces the state of the persisted {@link AuthenticatedClientSessionEntity} for the given session. + *

+ * All previously added changes via {@code addChange} for this session are discarded, and the provided task is + * executed to set the new state of the client session entity. + * + * @param key The identifier for the target client session. + * @param task The operation that must set the complete new state of the persisted entity. + * @throws NullPointerException if {@code key} or {@code task} is {@code null}. + */ + void restartEntity(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task); + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 9cb2035022e..f79428e46f2 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -57,6 +57,7 @@ import org.keycloak.models.UserSessionProvider; import org.keycloak.models.light.LightweightUserAdapter; import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction; +import org.keycloak.models.sessions.infinispan.changes.PersistentSessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.Tasks; @@ -87,7 +88,7 @@ import static org.keycloak.utils.StreamsUtil.paginatedStream; /** * @author Stian Thorgersen */ -public class InfinispanUserSessionProvider implements UserSessionProvider, SessionRefreshStore { +public class InfinispanUserSessionProvider implements UserSessionProvider, SessionRefreshStore, ClientSessionManager { private static final Logger log = Logger.getLogger(InfinispanUserSessionProvider.class); @@ -162,21 +163,18 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi @Override public AuthenticatedClientSessionModel createClientSession(RealmModel realm, ClientModel client, UserSessionModel userSession) { - InfinispanChangelogBasedTransaction clientSessionUpdateTx = clientSessionTx; final EmbeddedClientSessionKey key = new EmbeddedClientSessionKey(userSession.getId(), client.getId()); var entity = AuthenticatedClientSessionEntity.create(realm, client, userSession); - AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, entity, client, userSession, clientSessionUpdateTx, key, false); + AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, entity, client, userSession, this, key, false); // For now, the clientSession is considered transient in case that userSession was transient UserSessionModel.SessionPersistenceState persistenceState = userSession.getPersistenceState() != null ? userSession.getPersistenceState() : UserSessionModel.SessionPersistenceState.PERSISTENT; SessionUpdateTask createClientSessionTask = Tasks.addIfAbsentSync(); - clientSessionUpdateTx.addTask(key, createClientSessionTask, entity, persistenceState); - - sessionTx.addTask(userSession.getId(), new RegisterClientSessionTask(key.clientId())); - + clientSessionTx.addTask(key, createClientSessionTask, entity, persistenceState); + addClientSessionToUserSession(key, false); return adapter; } @@ -694,8 +692,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi } AuthenticatedClientSessionAdapter wrap(UserSessionModel userSession, ClientModel client, AuthenticatedClientSessionEntity entity, EmbeddedClientSessionKey key, boolean offline) { - InfinispanChangelogBasedTransaction clientSessionUpdateTx = getClientSessionTransaction(offline); - return entity != null ? new AuthenticatedClientSessionAdapter(session, entity, client, userSession, clientSessionUpdateTx, key, offline) : null; + return entity != null ? new AuthenticatedClientSessionAdapter(session, entity, client, userSession, this, key, offline) : null; } UserSessionEntity getUserSessionEntity(RealmModel realm, UserSessionModel userSession, boolean offline) { @@ -904,7 +901,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi userSessionUpdateTx.addTask(sessionToImportInto.getId(), new RegisterClientSessionTask(clientUUID)); - return new AuthenticatedClientSessionAdapter(session, entity, clientSession.getClient(), sessionToImportInto, clientSessionUpdateTx, key, true); + return new AuthenticatedClientSessionAdapter(session, entity, clientSession.getClient(), sessionToImportInto, this, key, true); } @@ -925,6 +922,21 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi return entity; } + @Override + public void addChange(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task) { + getClientSessionTransaction(task.isOffline()).addTask(key, task); + } + + @Override + public void restartEntity(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task) { + getClientSessionTransaction(task.isOffline()).restartEntity(key, task); + addClientSessionToUserSession(key, task.isOffline()); + } + + private void addClientSessionToUserSession(EmbeddedClientSessionKey cacheKey, boolean offline) { + getTransaction(offline).addTask(cacheKey.userSessionId(), new RegisterClientSessionTask(cacheKey.clientId())); + } + private record RegisterClientSessionTask(String clientUuid) implements SessionUpdateTask { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java index 28356630665..abe1e944b9e 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java @@ -64,6 +64,7 @@ import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.changes.ClientSessionPersistentChangelogBasedTransaction; import org.keycloak.models.sessions.infinispan.changes.JpaChangesPerformer; import org.keycloak.models.sessions.infinispan.changes.MergedUpdate; +import org.keycloak.models.sessions.infinispan.changes.PersistentSessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; import org.keycloak.models.sessions.infinispan.changes.SessionUpdateTask; import org.keycloak.models.sessions.infinispan.changes.SessionUpdatesList; @@ -93,7 +94,7 @@ import static org.keycloak.utils.StreamsUtil.paginatedStream; /** * @author Stian Thorgersen */ -public class PersistentUserSessionProvider implements UserSessionProvider, SessionRefreshStore { +public class PersistentUserSessionProvider implements UserSessionProvider, SessionRefreshStore, ClientSessionManager { private static final Logger log = Logger.getLogger(PersistentUserSessionProvider.class); @@ -155,9 +156,11 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi entity.getNotes().put(AuthenticatedClientSessionModel.USER_SESSION_REMEMBER_ME_NOTE, "true"); } - AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, entity, client, userSession, clientSessionTx, cacheKey, false); + final boolean offline = userSession.isOffline(); + AuthenticatedClientSessionAdapter adapter = new AuthenticatedClientSessionAdapter(session, entity, client, + userSession, this, cacheKey, false); - if (userSession.isOffline()) { + if (offline) { // If this is an offline session, and the referred online session doesn't exist anymore, don't register the client session in the transaction. // Instead keep it transient and it will be added to the offline session only afterward. This is expected by SessionTimeoutsTest.testOfflineUserClientIdleTimeoutSmallerThanSessionOneRefresh. if (sessionTx.get(realm, userSession.getId(), userSession, false) == null) { @@ -171,7 +174,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi SessionUpdateTask createClientSessionTask = Tasks.addIfAbsentSync(); clientSessionTx.addTask(cacheKey, createClientSessionTask, entity, persistenceState); - sessionTx.registerClientSession(userSession.getId(), client.getId(), userSession.isOffline()); + addClientSessionToUserSession(cacheKey, offline); return adapter; } @@ -301,7 +304,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi var key = new EmbeddedClientSessionKey(userSession.getId(), client.getId()); SessionEntityWrapper clientSessionEntity = clientSessionTx.get(client.getRealm(), client, userSession, key, offline); if (clientSessionEntity != null) { - return new AuthenticatedClientSessionAdapter(session, clientSessionEntity.getEntity(), client, userSession, clientSessionTx, key, offline); + return new AuthenticatedClientSessionAdapter(session, clientSessionEntity.getEntity(), client, userSession, this, key, offline); } return null; @@ -779,7 +782,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi sessionToImportInto.getEntity().getClientSessions().add(clientUUID); sessionTx.registerClientSession(sessionToImportInto.getId(), clientUUID, true); - return new AuthenticatedClientSessionAdapter(session, entity, clientSession.getClient(), sessionToImportInto, clientSessionTx, key, true); + return new AuthenticatedClientSessionAdapter(session, entity, clientSession.getClient(), sessionToImportInto, this, key, true); } public SessionEntityWrapper wrapPersistentEntity(RealmModel realm, boolean offline, UserSessionModel persistentUserSession) { @@ -940,4 +943,18 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi clientSessionPerformer.clear(); } + @Override + public void addChange(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task) { + clientSessionTx.addTask(key, task); + } + + @Override + public void restartEntity(EmbeddedClientSessionKey key, PersistentSessionUpdateTask task) { + clientSessionTx.restartEntity(key, task); + addClientSessionToUserSession(key, task.isOffline()); + } + + private void addClientSessionToUserSession(EmbeddedClientSessionKey cacheKey, boolean offline) { + sessionTx.registerClientSession(cacheKey.userSessionId(), cacheKey.clientId(), offline); + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java index 13b09fc1bb8..0708ab92a82 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java @@ -365,7 +365,7 @@ public class UserSessionAdapter imp @Override public void addTask(K key, SessionUpdateTask task) { SessionUpdatesList myUpdates = updates.get(key); - if (myUpdates == null) { - // Lookup entity from cache - SessionEntityWrapper wrappedEntity = cacheHolder.cache().get(key); - if (wrappedEntity == null) { - logger.tracef("Not present cache item for key %s", key); - return; - } - - RealmModel realm = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); - - myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); - updates.put(key, myUpdates); + if (myUpdates != null) { + myUpdates.addAndExecute(task); + return; } + lookupAndAndExecuteTask(key, task); + } - // Run the update now, so reader in same transaction can see it (TODO: Rollback may not work correctly. See if it's an issue..) - task.runUpdate(myUpdates.getEntityWrapper().getEntity()); - myUpdates.add(task); + @Override + public void restartEntity(K key, SessionUpdateTask restartTask) { + SessionUpdatesList myUpdates = updates.get(key); + if (myUpdates != null) { + myUpdates.getUpdateTasks().clear(); + myUpdates.addAndExecute(restartTask); + return; + } + lookupAndAndExecuteTask(key, restartTask); } @@ -90,8 +89,7 @@ public class InfinispanChangelogBasedTransaction imp if (task != null) { // Run the update now, so reader in same transaction can see it - task.runUpdate(entity); - myUpdates.add(task); + myUpdates.addAndExecute(task); } } @@ -268,4 +266,20 @@ public class InfinispanChangelogBasedTransaction imp allSessions.forEach((key, wrapper) -> updates.put(key, new SessionUpdatesList<>(realmModel, wrapper))); } + private void lookupAndAndExecuteTask(K key, SessionUpdateTask task) { + // Lookup entity from cache + SessionEntityWrapper wrappedEntity = cacheHolder.cache().get(key); + if (wrappedEntity == null) { + logger.tracef("Not present cache item for key %s", key); + return; + } + + RealmModel realm = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); + + SessionUpdatesList myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); + updates.put(key, myUpdates); + + // Run the update now, so reader in same transaction can see it (TODO: Rollback may not work correctly. See if it's an issue..) + myUpdates.addAndExecute(task); + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java index fb75273d659..1c32c3ed8bf 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java @@ -161,25 +161,44 @@ abstract public class PersistentSessionsChangelogBasedTransaction myUpdates = getUpdates(task.isOffline()).get(key); - if (myUpdates == null) { - // Lookup entity from cache - SessionEntityWrapper wrappedEntity = getCache(task.isOffline()).get(key); - if (wrappedEntity == null) { - LOG.tracef("Not present cache item for key %s", key); - return; - } - // Cache does not contain the offline flag value so adding it - wrappedEntity.getEntity().setOffline(task.isOffline()); - - RealmModel realm = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); - - myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); - getUpdates(task.isOffline()).put(key, myUpdates); + if (myUpdates != null) { + myUpdates.addAndExecute(task); + return; } + lookupAndAndExecuteTask(key, task); + } + + @Override + public void restartEntity(K key, SessionUpdateTask restartTask) { + if (!(restartTask instanceof PersistentSessionUpdateTask task)) { + throw new IllegalArgumentException("Task must be instance of PersistentSessionUpdateTask"); + } + var myUpdates = getUpdates(task.isOffline()).get(key); + if (myUpdates != null) { + myUpdates.getUpdateTasks().clear(); + myUpdates.addAndExecute(task); + return; + } + lookupAndAndExecuteTask(key, task); + } + + private void lookupAndAndExecuteTask(K key, PersistentSessionUpdateTask task) { + // Lookup entity from cache + SessionEntityWrapper wrappedEntity = getCache(task.isOffline()).get(key); + if (wrappedEntity == null) { + LOG.tracef("Not present cache item for key %s", key); + return; + } + // Cache does not contain the offline flag value so adding it + wrappedEntity.getEntity().setOffline(task.isOffline()); + + RealmModel realm = kcSession.realms().getRealm(wrappedEntity.getEntity().getRealmId()); + + SessionUpdatesList myUpdates = new SessionUpdatesList<>(realm, wrappedEntity); + getUpdates(task.isOffline()).put(key, myUpdates); // Run the update now, so reader in same transaction can see it (TODO: Rollback may not work correctly. See if it's an issue..) - task.runUpdate(myUpdates.getEntityWrapper().getEntity()); - myUpdates.add(task); + myUpdates.addAndExecute(task); } public void addTask(K key, SessionUpdateTask task, V entity, UserSessionModel.SessionPersistenceState persistenceState) { @@ -194,8 +213,7 @@ abstract public class PersistentSessionsChangelogBasedTransaction { public UserSessionModel.SessionPersistenceState getPersistenceState() { return persistenceState; } + + public void addAndExecute(SessionUpdateTask task) { + add(task); + task.runUpdate(getEntityWrapper().getEntity()); + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionsChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionsChangelogBasedTransaction.java index 694d51da497..74a07b8ac4d 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionsChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/SessionsChangelogBasedTransaction.java @@ -23,4 +23,6 @@ public interface SessionsChangelogBasedTransaction { void addTask(K key, SessionUpdateTask task); + void restartEntity(K key, SessionUpdateTask restartTask); + } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/Tasks.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/Tasks.java index 80958a67455..03454d30c64 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/Tasks.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/Tasks.java @@ -25,7 +25,7 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; */ public class Tasks { - private static final SessionUpdateTask ADD_IF_ABSENT_SYNC = new SessionUpdateTask() { + private static final SessionUpdateTask ADD_IF_ABSENT_SYNC = new SessionUpdateTask<>() { @Override public void runUpdate(SessionEntity entity) { } @@ -37,7 +37,7 @@ public class Tasks { }; - private static final SessionUpdateTask REMOVE_SYNC = new PersistentSessionUpdateTask() { + private static final SessionUpdateTask REMOVE_SYNC = new PersistentSessionUpdateTask<>() { @Override public void runUpdate(SessionEntity entity) { } @@ -94,8 +94,8 @@ public class Tasks { * @param * @return */ - public static SessionUpdateTask removeSync(boolean offline) { - return offline ? (SessionUpdateTask) OFFLINE_REMOVE_SYNC : (SessionUpdateTask) REMOVE_SYNC; + public static PersistentSessionUpdateTask removeSync(boolean offline) { + return offline ? (PersistentSessionUpdateTask) OFFLINE_REMOVE_SYNC : (PersistentSessionUpdateTask) REMOVE_SYNC; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/BaseUpdater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/BaseUpdater.java index ae60dbce742..8d4ec8904c9 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/BaseUpdater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/BaseUpdater.java @@ -34,6 +34,7 @@ public abstract class BaseUpdater implements Updater { private final K cacheKey; private final V cacheValue; private final long versionRead; + private final UpdaterState initialState; private UpdaterState state; protected BaseUpdater(K cacheKey, V cacheValue, long versionRead, UpdaterState state) { @@ -41,6 +42,7 @@ public abstract class BaseUpdater implements Updater { this.cacheValue = cacheValue; this.versionRead = versionRead; this.state = Objects.requireNonNull(state); + this.initialState = state; } @Override @@ -110,6 +112,13 @@ public abstract class BaseUpdater implements Updater { '}'; } + /** + * Resets the {@link UpdaterState} to its initial value. + */ + protected final void resetState() { + state = initialState; + } + /** * @return {@code true} if the entity was changed after being created/read. */ diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java index fbed0174bae..0b4e0c94cb1 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/client/AuthenticatedClientSessionUpdater.java @@ -210,6 +210,8 @@ public class AuthenticatedClientSessionUpdater extends BaseUpdater + * Any direct modification via the {@link Map} interface will throw an {@link UnsupportedOperationException}. To add a + * new mapping, use a method like {@link UserSessionProvider#createClientSession(RealmModel, ClientModel, UserSessionModel)} or + * equivalent. To remove a mapping, use {@link AuthenticatedClientSessionModel#detachFromUserSession()}. + */ +public interface AuthenticatedClientSessionMapping extends Map { + + /** + * Notifies the associated {@link UserSessionModel} has been restarted. + *

+ * All the {@link AuthenticatedClientSessionModel} must be detached. + */ + void onUserSessionRestart(); + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/user/UserSessionUpdater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/user/UserSessionUpdater.java index 749d7e79dbe..5480565c045 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/user/UserSessionUpdater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/user/UserSessionUpdater.java @@ -33,7 +33,7 @@ public class UserSessionUpdater extends BaseUpdater clientSessions; + private AuthenticatedClientSessionMapping clientSessions; private SessionPersistenceState persistenceState = SessionPersistenceState.PERSISTENT; private UserSessionUpdater(String cacheKey, RemoteUserSessionEntity cacheValue, long version, boolean offline, UpdaterState initialState) { @@ -205,7 +205,8 @@ public class UserSessionUpdater extends BaseUpdater userSessionEntity.restart(realm.getId(), user.getId(), loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId)); } @@ -232,7 +233,7 @@ public class UserSessionUpdater extends BaseUpdater clientSessions) { + public synchronized void initialize(SessionPersistenceState persistenceState, RealmModel realm, UserModel user, AuthenticatedClientSessionMapping clientSessions) { this.realm = Objects.requireNonNull(realm); this.user = Objects.requireNonNull(user); this.persistenceState = Objects.requireNonNull(persistenceState); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java index bac5d040537..282806919bc 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java @@ -37,6 +37,7 @@ public final class ClientSessionQueries { private static final String PER_CLIENT_COUNT = "SELECT e.clientId, count(e.clientId) FROM %s as e GROUP BY e.clientId ORDER BY e.clientId".formatted(CLIENT_SESSION); private static final String CLIENT_SESSION_COUNT = "SELECT count(e) FROM %s as e WHERE e.realmId = :realmId && e.clientId = :clientId".formatted(CLIENT_SESSION); private static final String FROM_USER_SESSION = "FROM %s as e WHERE e.userSessionId = :userSessionId ORDER BY e.clientId".formatted(CLIENT_SESSION); + private static final String IDS_FROM_USER_SESSION = "SELECT e.clientId FROM %s as e WHERE e.userSessionId = :userSessionId ORDER BY e.clientId".formatted(CLIENT_SESSION); /** * Returns a projection with the user session ID for client sessions from the client {@code clientId}. @@ -72,5 +73,15 @@ public final class ClientSessionQueries { .setParameter("userSessionId", userSessionId); } + /** + * Returns a projection with the client IDs belonging to the user session. + *

+ * The returned array contains a single {@link String} element with the client ID. + */ + public static Query fetchClientSessionsIds(RemoteCache cache, String userSessionId) { + return cache.query(IDS_FROM_USER_SESSION) + .setParameter("userSessionId", userSessionId); + } + } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProvider.java index 2b49c62ecfd..d566e8866ba 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProvider.java @@ -49,6 +49,7 @@ import org.keycloak.models.light.LightweightUserAdapter; import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.changes.remote.updater.BaseUpdater; import org.keycloak.models.sessions.infinispan.changes.remote.updater.client.AuthenticatedClientSessionUpdater; +import org.keycloak.models.sessions.infinispan.changes.remote.updater.user.AuthenticatedClientSessionMapping; import org.keycloak.models.sessions.infinispan.changes.remote.updater.user.UserSessionUpdater; import org.keycloak.models.sessions.infinispan.entities.ClientSessionKey; import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity; @@ -468,7 +469,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider { return updater; } - private class ClientSessionMapping extends AbstractMap implements Consumer { + private class ClientSessionMapping extends AbstractMap implements Consumer, AuthenticatedClientSessionMapping { private final UserSessionUpdater userSession; private boolean coldCache = true; @@ -477,11 +478,6 @@ public class RemoteUserSessionProvider implements UserSessionProvider { this.userSession = userSession; } - @Override - public void clear() { - getTransaction().removeByUserSessionId(getUserSessionId()); - } - @Override public AuthenticatedClientSessionModel get(Object key) { var updater = getTransaction().get(keyForClientId(key)); @@ -523,6 +519,11 @@ public class RemoteUserSessionProvider implements UserSessionProvider { return keyForClientId(String.valueOf(clientId)); } + private ClientSessionKey keyForClientId(Object[] projection) { + assert projection.length == 1; + return keyForClientId(String.valueOf(projection[0])); + } + private void fetchAndCacheClientSessions() { var query = ClientSessionQueries.fetchClientSessions(getTransaction().getCache(), getUserSessionId()); QueryHelper.streamAll(query, batchSize, Function.identity()).forEach(this); @@ -548,6 +549,21 @@ public class RemoteUserSessionProvider implements UserSessionProvider { private AuthenticatedClientSessionModel initialize(AuthenticatedClientSessionUpdater updater) { return initClientSessionUpdater(updater, userSession); } + + @Override + public void onUserSessionRestart() { + if (coldCache) { + // not all sessions cached in the transaction, we fetch the client ID and mark all them as deleted. + var query = ClientSessionQueries.fetchClientSessionsIds(getTransaction().getCache(), getUserSessionId()); + QueryHelper.streamAll(query, batchSize, this::keyForClientId) + .forEach(getTransaction()::remove); + coldCache = false; + return; + } + getTransaction().getClientSessions() + .filter(this::isFromUserSession) + .forEach(BaseUpdater::markDeleted); + } } private static Map.Entry toMapEntry(AuthenticatedClientSessionModel model) { diff --git a/server-spi/src/main/java/org/keycloak/models/UserSessionModel.java b/server-spi/src/main/java/org/keycloak/models/UserSessionModel.java index 934da3c2a5c..f00f4520859 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserSessionModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserSessionModel.java @@ -76,7 +76,10 @@ public interface UserSessionModel { /** * Returns map where key is ID of the client (its UUID) and value is ID respective {@link AuthenticatedClientSessionModel} object. - * @return + *

+ * Any direct modification via the {@link Map} interface will throw an {@link UnsupportedOperationException}. To add a + * new mapping, use a method like {@link UserSessionProvider#createClientSession(RealmModel, ClientModel, UserSessionModel)} or + * equivalent. To remove a mapping, use {@link AuthenticatedClientSessionModel#detachFromUserSession()}. */ Map getAuthenticatedClientSessions(); /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java index 53eea550f99..d65800e0cae 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java @@ -147,23 +147,30 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { try { KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), kcSession -> { kcSession.getContext().setRealm(realm); - UserSessionModel userSession = kcSession.sessions().getUserSession(realm, sessions[0].getId()); - assertSession(userSession, kcSession.users().getUserByUsername(realm, "user1"), "127.0.0.1", started, started, "test-app", "third-party"); + UserSessionModel userSession = kcSession.sessions().getUserSession(realm, sessions[1].getId()); + assertSession(userSession, kcSession.users().getUserByUsername(realm, "user1"), "127.0.0.2", started, started, "test-app"); + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(realm.getClientByClientId("test-app").getId()); + assertNotNull(clientSession); // add some dummy session notes just to test if restart bellow will work userSession.setNote("k1", "v1"); userSession.setNote("k2", "v2"); userSession.setNote("k3", "v3"); + // A user session can be restarted AuthenticationProcessor.attachSession() + // It invokes TokenManager.attachAuthenticationSession() that will restart an existing client session (if not valid) or create a new one. + // We test both cases here. "test-app" is restarted and "third-party" is a new client session userSession.restartSession(realm, kcSession.users().getUserByUsername(realm, "user2"), "user2", "127.0.0.6", "form", true, null, null); + clientSession.restartClientSession(); + createClientSession(kcSession, realm.getClientByClientId("third-party"), sessions[1], "http://redirect", "state"); }); KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), kcSession -> { - UserSessionModel userSession = kcSession.sessions().getUserSession(realm, sessions[0].getId()); + UserSessionModel userSession = kcSession.sessions().getUserSession(realm, sessions[1].getId()); assertThat(userSession.getNotes(), Matchers.anEmptyMap()); - assertSession(userSession, kcSession.users().getUserByUsername(realm, "user2"), "127.0.0.6", started + 100, started + 100); + assertSession(userSession, kcSession.users().getUserByUsername(realm, "user2"), "127.0.0.6", started + 100, started + 100, "test-app", "third-party"); }); } finally { Time.setOffset(0);