From 73922250d31b8f74e2dd73118409619dcc34372a Mon Sep 17 00:00:00 2001 From: Yike Gao Date: Wed, 8 Jul 2026 15:27:20 +0100 Subject: [PATCH] Invalidate in progress authentication sessions on credential reset Closes #50621 Signed-off-by: Yike Gao --- ...finispanAuthenticationSessionProvider.java | 12 +++ ...icationSessionQueryConditionalRemover.java | 102 ++++++++++++++++++ .../RootAuthenticationSessionEntity.java | 20 +++- ...finispanAuthenticationSessionProvider.java | 6 ++ ...nAuthenticationSessionProviderFactory.java | 4 +- ...enticationSessionChangeLogTransaction.java | 12 ++- .../jpa/JpaAuthenticationSessionProvider.java | 11 ++ .../jpa/RootAuthenticationSessionEntity.java | 10 ++ .../META-INF/jpa-changelog-26.8.0.xml | 28 +++++ .../META-INF/jpa-changelog-master.xml | 1 + .../AuthenticationSessionProvider.java | 12 +++ .../authentication/AuthenticatorUtil.java | 2 + .../RequiredActionResetPasswordTest.java | 31 ++++++ .../session/AuthenticationSessionTest.java | 51 +++++++++ 14 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java create mode 100644 model/jpa/src/main/resources/META-INF/jpa-changelog-26.8.0.xml diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java index 02eb6dd2f4ff..c6f51b4f936a 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java @@ -27,6 +27,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent; import org.keycloak.models.sessions.infinispan.changes.InfinispanChangelogBasedTransaction; @@ -96,6 +97,17 @@ private RootAuthenticationSessionEntity getRootAuthenticationSessionEntity(Strin return entityWrapper==null ? null : entityWrapper.getEntity(); } + @Override + public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) { + sessionTx.getCache().entrySet().stream() + .filter(SessionWrapperPredicate.create(realm.getId())) + .filter(entry -> entry.getValue().getEntity().hasAuthenticationSessionForUser(user.getId())) + .map(entry -> entry.getKey()) + .filter(rootSessionId -> !Objects.equals(rootSessionId, rootAuthenticationSessionIdToKeep)) + .toList() + .forEach(rootSessionId -> sessionTx.addTask(rootSessionId, Tasks.removeSync())); + } + @Override public void onRealmRemoved(RealmModel realm) { // Send message to all DCs. The remoteCache will notify client listeners on all DCs for remove authentication sessions diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java new file mode 100644 index 000000000000..c182cf92ffd3 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/remover/query/AuthenticationSessionQueryConditionalRemover.java @@ -0,0 +1,102 @@ +/* + * 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.changes.remote.remover.query; + +import java.util.Map; +import java.util.Objects; + +import org.keycloak.models.sessions.infinispan.changes.remote.remover.ConditionalRemover; +import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; + +/** + * A {@link ConditionalRemover} implementation to delete {@link RootAuthenticationSessionEntity} based on either the + * realm or the authenticated user. + * + * The removal is performed server-side with a single Infinispan Ickle query. Removing by user relies on the indexed + * {@code authenticatedUserIds} field of {@link RootAuthenticationSessionEntity}. + */ +public class AuthenticationSessionQueryConditionalRemover extends MultipleConditionQueryRemover { + + private final String entity; + + public AuthenticationSessionQueryConditionalRemover(String entity) { + this.entity = entity; + } + + @Override + String getEntity() { + return entity; + } + + public void removeByRealmId(String realmId) { + add(new RemoveByRealm(nextParameter(), realmId)); + } + + public void removeByUser(String realmId, String userId, String rootAuthenticationSessionIdToKeep) { + var keepParameter = rootAuthenticationSessionIdToKeep == null ? null : nextParameter(); + add(new RemoveByUser(nextParameter(), realmId, nextParameter(), userId, keepParameter, rootAuthenticationSessionIdToKeep)); + } + + private record RemoveByRealm(String realmParameter, + String realmId) implements RemoveCondition { + + @Override + public String getConditionalClause() { + return "(realmId = :%s)".formatted(realmParameter); + } + + @Override + public void addParameters(Map parameters) { + parameters.put(realmParameter, realmId); + } + + @Override + public boolean willRemove(String key, RootAuthenticationSessionEntity value) { + return Objects.equals(realmId, value.getRealmId()); + } + } + + private record RemoveByUser(String realmParameter, String realmId, String userParameter, String userId, + String keepParameter, String rootAuthenticationSessionIdToKeep) + implements RemoveCondition { + + @Override + public String getConditionalClause() { + if (keepParameter == null) { + return "(realmId = :%s && authenticatedUserIds = :%s)".formatted(realmParameter, userParameter); + } + return "(realmId = :%s && authenticatedUserIds = :%s && id != :%s)".formatted(realmParameter, userParameter, keepParameter); + } + + @Override + public void addParameters(Map parameters) { + parameters.put(realmParameter, realmId); + parameters.put(userParameter, userId); + if (keepParameter != null) { + parameters.put(keepParameter, rootAuthenticationSessionIdToKeep); + } + } + + @Override + public boolean willRemove(String key, RootAuthenticationSessionEntity value) { + return Objects.equals(realmId, value.getRealmId()) + && value.hasAuthenticationSessionForUser(userId) + && !Objects.equals(key, rootAuthenticationSessionIdToKeep); + } + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/RootAuthenticationSessionEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/RootAuthenticationSessionEntity.java index 6423d1f1dac0..bacd3a51f38f 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/RootAuthenticationSessionEntity.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/RootAuthenticationSessionEntity.java @@ -17,12 +17,16 @@ package org.keycloak.models.sessions.infinispan.entities; +import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import org.keycloak.marshalling.Marshalling; +import org.infinispan.api.annotations.indexing.Basic; import org.infinispan.api.annotations.indexing.Indexed; import org.infinispan.protostream.annotations.ProtoFactory; import org.infinispan.protostream.annotations.ProtoField; @@ -51,7 +55,7 @@ protected RootAuthenticationSessionEntity(String realmId, String id, int timesta } @ProtoFactory - static RootAuthenticationSessionEntity protoFactory(String realmId, String id, int timestamp, Map authenticationSessions) { + static RootAuthenticationSessionEntity protoFactory(String realmId, String id, int timestamp, Map authenticationSessions, Set authenticatedUserIds) { return new RootAuthenticationSessionEntity(realmId, id, timestamp, authenticationSessions); } @@ -78,6 +82,20 @@ public void setAuthenticationSessions(Map a this.authenticationSessions = authenticationSessions; } + @ProtoField(value = 5, collectionImplementation = HashSet.class) + @Basic + public Set getAuthenticatedUserIds() { + return authenticationSessions.values().stream() + .map(AuthenticationSessionEntity::getAuthUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + public boolean hasAuthenticationSessionForUser(String userId) { + return authenticationSessions.values().stream() + .anyMatch(authSession -> Objects.equals(authSession.getAuthUserId(), userId)); + } + @Override public boolean shouldEvaluateRemoval() { return true; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java index 57db95ec06da..e582ade2ece5 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java @@ -26,6 +26,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent; import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory; import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; @@ -84,6 +85,11 @@ public void removeRootAuthenticationSession(RealmModel realm, RootAuthentication transaction.remove(authenticationSession.getId()); } + @Override + public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) { + transaction.removeByUser(realm.getId(), user.getId(), rootAuthenticationSessionIdToKeep); + } + @Override public void onRealmRemoved(RealmModel realm) { transaction.removeByRealmId(realm.getId()); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProviderFactory.java index efe0083a362f..bf0a96ad48d3 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProviderFactory.java @@ -31,7 +31,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory; -import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.ByRealmIdQueryConditionalRemover; +import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.AuthenticationSessionQueryConditionalRemover; import org.keycloak.models.sessions.infinispan.changes.remote.updater.UpdaterFactory; import org.keycloak.models.sessions.infinispan.changes.remote.updater.authsession.RootAuthenticationSessionUpdater; import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; @@ -155,7 +155,7 @@ public Set> dependsOn() { private AuthenticationSessionChangeLogTransaction createAndEnlistTransaction(KeycloakSession session) { var provider = session.getProvider(InfinispanTransactionProvider.class); - var tx = new AuthenticationSessionChangeLogTransaction(this, this, new ByRealmIdQueryConditionalRemover<>(PROTO_ENTITY)); + var tx = new AuthenticationSessionChangeLogTransaction(this, this, new AuthenticationSessionQueryConditionalRemover(PROTO_ENTITY)); provider.registerTransaction(tx); return tx; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/AuthenticationSessionChangeLogTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/AuthenticationSessionChangeLogTransaction.java index 68bbfa81fc5d..ef219711a910 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/AuthenticationSessionChangeLogTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/AuthenticationSessionChangeLogTransaction.java @@ -17,7 +17,7 @@ package org.keycloak.models.sessions.infinispan.remote.transaction; -import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.ByRealmIdQueryConditionalRemover; +import org.keycloak.models.sessions.infinispan.changes.remote.remover.query.AuthenticationSessionQueryConditionalRemover; import org.keycloak.models.sessions.infinispan.changes.remote.updater.UpdaterFactory; import org.keycloak.models.sessions.infinispan.changes.remote.updater.authsession.RootAuthenticationSessionUpdater; import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; @@ -25,15 +25,19 @@ /** * Syntactic sugar for * {@code RemoteInfinispanKeycloakTransaction> + * AuthenticationSessionQueryConditionalRemover> */ -public class AuthenticationSessionChangeLogTransaction extends RemoteChangeLogTransaction> { +public class AuthenticationSessionChangeLogTransaction extends RemoteChangeLogTransaction { - public AuthenticationSessionChangeLogTransaction(UpdaterFactory factory, SharedState sharedState, ByRealmIdQueryConditionalRemover conditionalRemover) { + public AuthenticationSessionChangeLogTransaction(UpdaterFactory factory, SharedState sharedState, AuthenticationSessionQueryConditionalRemover conditionalRemover) { super(factory, sharedState, conditionalRemover); } public void removeByRealmId(String realmId) { getConditionalRemover().removeByRealmId(realmId); } + + public void removeByUser(String realmId, String userId, String rootAuthenticationSessionIdToKeep) { + getConditionalRemover().removeByUser(realmId, userId, rootAuthenticationSessionIdToKeep); + } } diff --git a/model/jpa/src/main/java/org/keycloak/authentication/jpa/JpaAuthenticationSessionProvider.java b/model/jpa/src/main/java/org/keycloak/authentication/jpa/JpaAuthenticationSessionProvider.java index 5916ae46a90b..249e487c54e1 100644 --- a/model/jpa/src/main/java/org/keycloak/authentication/jpa/JpaAuthenticationSessionProvider.java +++ b/model/jpa/src/main/java/org/keycloak/authentication/jpa/JpaAuthenticationSessionProvider.java @@ -32,6 +32,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.utils.SessionExpiration; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.RootAuthenticationSessionModel; @@ -142,6 +143,16 @@ public void removeRootAuthenticationSession(RealmModel realm, RootAuthentication } } + @Override + public void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) { + getEntityManager() + .createNamedQuery("deleteRootAuthSessionsByUser") + .setParameter("realmId", realm.getId()) + .setParameter("userId", user.getId()) + .setParameter("rootSessionIdToKeep", rootAuthenticationSessionIdToKeep) + .executeUpdate(); + } + @Override public void onRealmRemoved(RealmModel realm) { getEntityManager() diff --git a/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java b/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java index 7fd97e001e56..236f667e5a07 100644 --- a/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java @@ -47,6 +47,16 @@ query = "SELECT sess.id FROM RootAuthenticationSessionEntity sess" + " WHERE sess.realmId = :realmId AND sess.timestamp < :timestamp" ), + @NamedQuery( + name = "deleteRootAuthSessionsByUser", + query = "DELETE FROM RootAuthenticationSessionEntity sess" + + " WHERE sess.realmId = :realmId" + + " AND (:rootSessionIdToKeep IS NULL OR sess.id <> :rootSessionIdToKeep)" + + " AND EXISTS (" + + " SELECT auth.tabId FROM AuthenticationSessionEntity auth" + + " WHERE auth.rootAuthenticationSession = sess AND auth.authUserId = :userId" + + " )" + ), @NamedQuery( name = "deleteExpiredRootAuthSessionByIds", query = "DELETE FROM RootAuthenticationSessionEntity e WHERE e.id IN :ids AND e.timestamp < :timestamp" diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.8.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.8.0.xml new file mode 100644 index 000000000000..e1c5b118d729 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.8.0.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml index 0adf7e611264..d3552733bc5f 100755 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml @@ -92,5 +92,6 @@ + diff --git a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java index a1792cd67eda..f3f9a37e875b 100644 --- a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java +++ b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java @@ -22,6 +22,7 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; /** @@ -82,6 +83,17 @@ default void removeAllExpired() {} @Deprecated(since = "19.0", forRemoval = true) default void removeExpired(RealmModel realm) {} + /** + * Removes all root authentication sessions of the given realm that hold an in-progress authentication session + * for the given authenticated user, except for the provided root authentication session id. + * + * @param realm {@code RealmModel} Can't be {@code null}. + * @param user {@code UserModel} Can't be {@code null}. + * @param rootAuthenticationSessionIdToKeep optional id of a root authentication session to keep. + */ + default void removeRootAuthenticationSessionsByAuthenticatedUser(RealmModel realm, UserModel user, String rootAuthenticationSessionIdToKeep) { + } + /** * Removes all associated root authentication sessions to the given realm which was removed. * @param realm {@code RealmModel} Can't be {@code null}. diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java index e96a4ba24673..0cc35d8aa00b 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -215,6 +215,8 @@ private static void logoutOtherSessions(KeycloakSession session, RealmModel real }); } + session.authenticationSessions().removeRootAuthenticationSessionsByAuthenticatedUser(realm, user, + authSession.getParentSession().getId()); } private static void backchannelLogout(KeycloakSession session, RealmModel realm, ClientConnection conn, HttpRequest req, EventBuilder event, UserSessionModel s) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionResetPasswordTest.java index f8dba6099d37..afc79569bce9 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionResetPasswordTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionResetPasswordTest.java @@ -56,6 +56,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.jupiter.api.Assertions; +import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -136,6 +137,36 @@ public void resetPasswordLogoutSessionsNotChecked() { resetPassword(false); } + @Test + public void resetPasswordLogoutSessionsInvalidatesInProgressAuthSession() { + requireUpdatePassword(); + UserResource testUser = managedRealm.admin().users().get(findUser("test-user@localhost").getId()); + + // Browser attacker: authenticate with the current password and stop on the update-password screen. + OAuthClient oauth2 = oauth.newConfig().driver(driver2); + oauth2.openLoginForm(); + driver2.findElement(By.id("username")).sendKeys("test-user@localhost"); + driver2.findElement(By.id("password")).sendKeys("password"); + driver2.findElement(By.id("password")).submit(); + MatcherAssert.assertThat("Browser 2 should be parked on the update-password page", + driver2.findElements(By.id("password-new")).isEmpty(), Matchers.is(false)); + + // Browser victim: reset the password and choose to sign out of other devices. + oauth.openLoginForm(); + loginPage.login("test-user@localhost", "password"); + changePasswordPage.assertCurrent(); + changePasswordPage.checkLogoutSessions(); + changePasswordPage.changePassword("new-password", "new-password"); + Assertions.assertTrue(oauth.parseLoginResponse().isSuccess()); + assertEquals(1, testUser.getUserSessions().size()); + + // Browser attacker: try to finish the parked flow. Its in-progress authentication session was deleted by the reset, so it must not be able to complete and must not create a user session. + driver2.findElement(By.id("password-new")).sendKeys("attacker-password"); + driver2.findElement(By.id("password-confirm")).sendKeys("attacker-password"); + driver2.findElement(By.id("password-confirm")).submit(); + assertEquals(1, testUser.getUserSessions().size()); + } + private void resetPassword(boolean logoutOtherSessions) { // create a regular session OAuthClient oauth2 = oauth.newConfig().driver(driver2); diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/AuthenticationSessionTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/AuthenticationSessionTest.java index c248e248a795..8a34313b31c5 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/AuthenticationSessionTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/AuthenticationSessionTest.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.model.session; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -31,6 +32,7 @@ import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.RootAuthenticationSessionModel; @@ -184,6 +186,55 @@ public void testAuthSessions() { }); } + @Test + public void testRemoveRootAuthenticationSessionsByAuthenticatedUser() { + AtomicReference user1Id = new AtomicReference<>(); + List user1RootIds = new ArrayList<>(); + AtomicReference user2RootId = new AtomicReference<>(); + AtomicReference anonymousRootId = new AtomicReference<>(); + + withRealm(realmId, (session, realm) -> { + UserModel user1 = session.users().addUser(realm, "user1"); + UserModel user2 = session.users().addUser(realm, "user2"); + user1Id.set(user1.getId()); + ClientModel client = realm.getClientByClientId("test-app"); + + // two separate in-progress authentication sessions authenticated as user1 (e.g. two devices) + for (int i = 0; i < 2; i++) { + RootAuthenticationSessionModel root = session.authenticationSessions().createRootAuthenticationSession(realm); + root.createAuthenticationSession(client).setAuthenticatedUser(user1); + user1RootIds.add(root.getId()); + } + // one authenticated as user2 + RootAuthenticationSessionModel root2 = session.authenticationSessions().createRootAuthenticationSession(realm); + root2.createAuthenticationSession(client).setAuthenticatedUser(user2); + user2RootId.set(root2.getId()); + + // one without an authenticated user (credentials not verified yet) + RootAuthenticationSessionModel anon = session.authenticationSessions().createRootAuthenticationSession(realm); + anon.createAuthenticationSession(client); + anonymousRootId.set(anon.getId()); + return null; + }); + + // emulate "logout other sessions": remove all of user1's in-progress sessions except the current one + String currentRootId = user1RootIds.get(0); + withRealm(realmId, (session, realm) -> { + UserModel user1 = session.users().getUserById(realm, user1Id.get()); + session.authenticationSessions().removeRootAuthenticationSessionsByAuthenticatedUser(realm, user1, currentRootId); + return null; + }); + + // the current session survives; the other user1 session is gone; other users are untouched + withRealm(realmId, (session, realm) -> { + assertThat(session.authenticationSessions().getRootAuthenticationSession(realm, currentRootId), Matchers.notNullValue()); + assertThat(session.authenticationSessions().getRootAuthenticationSession(realm, user1RootIds.get(1)), Matchers.nullValue()); + assertThat(session.authenticationSessions().getRootAuthenticationSession(realm, user2RootId.get()), Matchers.notNullValue()); + assertThat(session.authenticationSessions().getRootAuthenticationSession(realm, anonymousRootId.get()), Matchers.notNullValue()); + return null; + }); + } + @Test public void testRemoveExpiredAuthSessions() { AtomicReference rootAuthSessionId = new AtomicReference<>();