From 18eeef7b261e3fa34074acbdb06bef2b6c2f770b Mon Sep 17 00:00:00 2001 From: Pedro Ruivo Date: Fri, 7 Nov 2025 22:36:47 +0000 Subject: [PATCH] Create user session expired event Closes #43942 Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Signed-off-by: Alexander Schwartz Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Co-authored-by: Alexander Schwartz --- .../InfinispanUserSessionProviderFactory.java | 11 ++ .../changes/remote/updater/BaseUpdater.java | 17 +- .../changes/remote/updater/Updater.java | 17 ++ .../BaseUserSessionExpirationListener.java | 69 ++++++++ ...EmbeddedUserSessionExpirationListener.java | 45 +++++ .../RemoteUserSessionExpirationListener.java | 82 +++++++++ .../remote/RemoteUserSessionProvider.java | 6 +- .../RemoteUserSessionProviderFactory.java | 12 +- .../RemoteChangeLogTransaction.java | 10 +- .../JpaUserSessionPersisterProvider.java | 64 +++++-- .../PersistentClientSessionEntity.java | 15 +- .../session/PersistentUserSessionEntity.java | 10 +- .../org/keycloak/config/EventOptions.java | 2 +- .../it/cli/dist/HelpCommandDistTest.java | 2 +- .../java/org/keycloak/events/Details.java | 2 + .../java/org/keycloak/events/EventType.java | 9 +- .../org/keycloak/testsuite/AssertEvents.java | 64 +++++-- .../testsuite/oauth/OfflineTokenTest.java | 139 +++++++-------- .../testsuite/oauth/RefreshTokenTest.java | 135 +++++++------- testsuite/model/pom.xml | 6 + .../session/UserSessionExpirationTest.java | 167 ++++++++++++++++++ .../UserSessionPersisterProviderTest.java | 7 +- 22 files changed, 681 insertions(+), 210 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/BaseUserSessionExpirationListener.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/EmbeddedUserSessionExpirationListener.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/RemoteUserSessionExpirationListener.java create mode 100644 testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java index 9810257300a..9712748ec10 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProviderFactory.java @@ -53,6 +53,7 @@ import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; import org.keycloak.models.sessions.infinispan.events.AbstractUserSessionClusterListener; import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent; +import org.keycloak.models.sessions.infinispan.listeners.EmbeddedUserSessionExpirationListener; import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProvider; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.models.utils.KeycloakModelUtils; @@ -89,6 +90,7 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider private CacheHolder offlineSessionCacheHolder; private CacheHolder clientSessionCacheHolder; private CacheHolder offlineClientSessionCacheHolder; + private EmbeddedUserSessionExpirationListener expirationListener; private long offlineSessionCacheEntryLifespanOverride; @@ -203,7 +205,12 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider offlineSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_USER_SESSION_CACHE_NAME, this::deriveOfflineSessionCacheEntryLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs); clientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, CLIENT_SESSION_CACHE_NAME, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs); offlineClientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_CLIENT_SESSION_CACHE_NAME, this::deriveOfflineClientSessionCacheEntryLifespanOverrideMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs); + var blockingManager = session.getProvider(InfinispanConnectionProvider.class).getBlockingManager(); + expirationListener = new EmbeddedUserSessionExpirationListener(session.getKeycloakSessionFactory(), blockingManager); } + // Only add the event listener to session caches + // The expired events for offline sessions will be triggered by JpaUserSessionPersisterProvider + sessionCacheHolder.cache().addListener(expirationListener); } } @@ -291,6 +298,10 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider if (persistentSessionsWorker != null) { persistentSessionsWorker.stop(); } + if (expirationListener != null) { + sessionCacheHolder.cache().removeListener(expirationListener); + expirationListener = null; + } } @Override 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 8d4ec8904c9..7b511a1227a 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 @@ -75,14 +75,24 @@ public abstract class BaseUpdater implements Updater { return state == UpdaterState.READ && isUnchanged(); } + @Override + public final boolean isExpired() { + return state == UpdaterState.EXPIRED; + } + @Override public final void markDeleted() { state = switch (state) { case READ, DELETED -> UpdaterState.DELETED; - case CREATED, DELETED_TRANSIENT -> UpdaterState.DELETED_TRANSIENT; + case CREATED, DELETED_TRANSIENT, EXPIRED -> UpdaterState.DELETED_TRANSIENT; }; } + @Override + public void markExpired() { + state = UpdaterState.EXPIRED; + } + @Override public boolean isTransient() { return state == UpdaterState.DELETED_TRANSIENT; @@ -141,5 +151,10 @@ public abstract class BaseUpdater implements Updater { * The entity is transient (it won't be updated in the external infinispan cluster) and deleted. */ DELETED_TRANSIENT, + /** + * The entity is expired (max-idle or lifespan). No changes should be applied. + */ + EXPIRED, + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/Updater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/Updater.java index e3f4c8af314..faf3c3843c5 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/Updater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/Updater.java @@ -65,11 +65,28 @@ public interface Updater extends BiFunction { */ boolean isReadOnly(); + /** + * @return {@code true} if the entity is expired. + */ + boolean isExpired(); + + /** + * @return {@code true} if the entity is not valid and cannot be viewed/accessed from the transaction. + */ + default boolean isInvalid() { + return isExpired() || isDeleted(); + } + /** * Marks the entity as deleted. */ void markDeleted(); + /** + * Marks the entity as expired when loading from the Infinispan cache. + */ + void markExpired(); + /** * @return {@code true} if the entity is transient and shouldn't be stored in the Infinispan cache. */ diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/BaseUserSessionExpirationListener.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/BaseUserSessionExpirationListener.java new file mode 100644 index 00000000000..4c2ceb8c6f4 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/BaseUserSessionExpirationListener.java @@ -0,0 +1,69 @@ +/* + * 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.listeners; + +import java.lang.invoke.MethodHandles; + +import org.infinispan.util.concurrent.BlockingManager; +import org.jboss.logging.Logger; +import org.keycloak.events.Details; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +/** + * Base class to handle expired user session. + *

+ * It offloads the event creating and sending to a different thread to avoid blocking the caller. + */ +abstract class BaseUserSessionExpirationListener { + + protected static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass()); + private final KeycloakSessionFactory factory; + private final BlockingManager blockingManager; + + BaseUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager) { + this.factory = factory; + this.blockingManager = blockingManager; + } + + protected void sendExpirationEvent(String userSessionId, String userId, String realmId) { + blockingManager.runBlocking(() -> doSend(userSessionId, userId, realmId), "expired-" + userSessionId); + } + + private void doSend(String userSessionId, String userId, String realmId) { + KeycloakModelUtils.runJobInTransaction(factory, session -> { + logger.debugf("User session expired. sessionId=%s, userId=%s, realmId=%s", userSessionId, userId, realmId); + + RealmModel realm = session.realms().getRealm(realmId); + if (realm == null) { + return; + } + session.getContext().setRealm(realm); + new EventBuilder(realm, session) + .session(userSessionId) + .user(userId) + .event(EventType.USER_SESSION_DELETED) + .detail(Details.REASON, Details.EXPIRED_DETAIL) + .success(); + }); + } + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/EmbeddedUserSessionExpirationListener.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/EmbeddedUserSessionExpirationListener.java new file mode 100644 index 00000000000..6ce27cab1c2 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/EmbeddedUserSessionExpirationListener.java @@ -0,0 +1,45 @@ +/* + * 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.listeners; + +import org.infinispan.notifications.Listener; +import org.infinispan.notifications.cachelistener.annotation.CacheEntryExpired; +import org.infinispan.notifications.cachelistener.event.CacheEntryExpiredEvent; +import org.infinispan.util.concurrent.BlockingManager; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper; +import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity; + +/** + * A listener for embedded Infinispan caches. + *

+ * It listens to the {@link CacheEntryExpired} events for user sessions. + */ +@Listener(primaryOnly = true) +public class EmbeddedUserSessionExpirationListener extends BaseUserSessionExpirationListener { + + public EmbeddedUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager) { + super(factory, blockingManager); + } + + @CacheEntryExpired + public void onSessionExpired(CacheEntryExpiredEvent> event) { + UserSessionEntity entity = event.getValue().getEntity(); + sendExpirationEvent(entity.getId(), entity.getUser(), entity.getRealmId()); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/RemoteUserSessionExpirationListener.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/RemoteUserSessionExpirationListener.java new file mode 100644 index 00000000000..ee759a74ab3 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/listeners/RemoteUserSessionExpirationListener.java @@ -0,0 +1,82 @@ +/* + * 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.listeners; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.infinispan.client.hotrod.annotation.ClientCacheEntryExpired; +import org.infinispan.client.hotrod.annotation.ClientListener; +import org.infinispan.client.hotrod.event.ClientCacheEntryCustomEvent; +import org.infinispan.commons.io.UnsignedNumeric; +import org.infinispan.commons.marshall.Marshaller; +import org.infinispan.util.concurrent.BlockingManager; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity; + +/** + * A listener for remote Infinispan caches. + *

+ * It listens to the {@link ClientCacheEntryExpired} events for user sessions. + */ +@ClientListener(converterFactoryName = "___eager-key-value-version-converter", useRawData = true) +public class RemoteUserSessionExpirationListener extends BaseUserSessionExpirationListener { + + private final Marshaller marshaller; + + public RemoteUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager, Marshaller marshaller) { + super(factory, blockingManager); + this.marshaller = marshaller; + } + + @ClientCacheEntryExpired + public void onSessionExpired(ClientCacheEntryCustomEvent entryExpired) { + try { + RemoteUserSessionEntity entity = extractRemoteUserSessionEntity(entryExpired); + if (entity == null) { + return; + } + sendExpirationEvent(entity.getUserSessionId(), entity.getUserId(), entity.getRealmId()); + } catch (Exception e) { + logger.error("Error handling an expired entry", e); + } + } + + private RemoteUserSessionEntity extractRemoteUserSessionEntity(ClientCacheEntryCustomEvent event) throws IOException, ClassNotFoundException { + ByteBuffer rawData = ByteBuffer.wrap(event.getEventData()); + + // skip the key, we don't need it + skipElement(rawData); + + // read the value + Object value = marshaller.objectFromByteBuffer(readElement(rawData)); + return value instanceof RemoteUserSessionEntity ruse ? ruse : null; + } + + private static void skipElement(ByteBuffer buffer) { + int length = UnsignedNumeric.readUnsignedInt(buffer); + buffer.position(buffer.position() + length); + } + + private static byte[] readElement(ByteBuffer buffer) { + int length = UnsignedNumeric.readUnsignedInt(buffer); + byte[] element = new byte[length]; + buffer.get(element); + return element; + } +} 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 67bce8b4b4d..7837c67f83d 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 @@ -369,7 +369,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider { private UserSessionUpdater initUserSessionFromQuery(UserSessionUpdater updater, RealmModel realm, UserModel user, boolean offline) { assert updater != null; assert realm != null; - if (updater.isDeleted()) { + if (updater.isInvalid()) { return null; } if (updater.isInitialized()) { @@ -408,7 +408,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider { } private AuthenticatedClientSessionModel initClientSessionUpdater(AuthenticatedClientSessionUpdater updater, UserSessionUpdater userSession) { - if (updater == null || updater.isDeleted()) { + if (updater == null || updater.isInvalid()) { return null; } var client = userSession.getRealm().getClientById(updater.getKey().clientId()); @@ -464,7 +464,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider { private static > T checkExpiration(T updater) { var expiration = updater.computeExpiration(); if (expiration.isExpired()) { - updater.markDeleted(); + updater.markExpired(); return null; } return updater; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProviderFactory.java index 70986ee86d2..1c477bb3583 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserSessionProviderFactory.java @@ -22,6 +22,7 @@ import org.keycloak.models.sessions.infinispan.changes.remote.updater.user.UserS import org.keycloak.models.sessions.infinispan.entities.ClientSessionKey; import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity; import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity; +import org.keycloak.models.sessions.infinispan.listeners.RemoteUserSessionExpirationListener; import org.keycloak.models.sessions.infinispan.remote.transaction.ClientSessionChangeLogTransaction; import org.keycloak.models.sessions.infinispan.remote.transaction.RemoteChangeLogTransaction; import org.keycloak.models.sessions.infinispan.remote.transaction.UserSessionChangeLogTransaction; @@ -54,6 +55,7 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact private volatile int batchSize = DEFAULT_BATCH_SIZE; private volatile int maxRetries = InfinispanUtils.DEFAULT_MAX_RETRIES; private volatile int backOffBaseTimeMillis = InfinispanUtils.DEFAULT_RETRIES_BASE_TIME_MILLIS; + private volatile RemoteUserSessionExpirationListener expirationListener; @Override public RemoteUserSessionProvider create(KeycloakSession session) { @@ -80,6 +82,11 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact @Override public void close() { + if (expirationListener != null) { + userSessionState.cache().removeClientListener(expirationListener); + offlineUserSessionState.cache().removeClientListener(expirationListener); + } + expirationListener = null; blockingManager = null; userSessionState = null; offlineUserSessionState = null; @@ -131,7 +138,7 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact @Override public Set> dependsOn() { - return Set.of(InfinispanTransactionProvider.class); + return Set.of(InfinispanTransactionProvider.class, InfinispanConnectionProvider.class); } private void onUserRemoved(UserModel.UserRemovedEvent event) { @@ -149,6 +156,9 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact clientSessionState = new SharedStateImpl<>(connections.getRemoteCache(CLIENT_SESSION_CACHE_NAME)); offlineClientSessionState = new SharedStateImpl<>(connections.getRemoteCache(OFFLINE_CLIENT_SESSION_CACHE_NAME)); blockingManager = connections.getBlockingManager(); + expirationListener = new RemoteUserSessionExpirationListener(session.getKeycloakSessionFactory(), blockingManager, userSessionState.cache().getRemoteCacheContainer().getMarshaller()); + userSessionState.cache().addClientListener(expirationListener); + offlineUserSessionState.cache().addClientListener(expirationListener); } private UserSessionTransaction createTransaction(KeycloakSession session) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/RemoteChangeLogTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/RemoteChangeLogTransaction.java index 8642becdd34..deb0bbcd760 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/RemoteChangeLogTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/transaction/RemoteChangeLogTransaction.java @@ -68,7 +68,7 @@ public class RemoteChangeLogTransaction, R extends conditionalRemover.executeRemovals(getCache(), stage); for (var updater : entityChanges.values()) { - if (updater.isReadOnly() || updater.isTransient() || conditionalRemover.willRemove(updater)) { + if (updater.isReadOnly() || updater.isExpired() || updater.isTransient() || conditionalRemover.willRemove(updater)) { continue; } if (updater.isDeleted()) { @@ -79,7 +79,7 @@ public class RemoteChangeLogTransaction, R extends var expiration = updater.computeExpiration(); if (expiration.isExpired()) { - stage.dependsOn(commitRemove(updater)); + // We need the cache entry expired events from the server, do nothing here. continue; } @@ -121,7 +121,7 @@ public class RemoteChangeLogTransaction, R extends public T get(K key) { var updater = entityChanges.get(key); if (updater != null) { - return updater.isDeleted() ? null : updater; + return updater.isInvalid() ? null : updater; } return onEntityFromCache(key, getCache().getWithMetadata(key)); } @@ -135,7 +135,7 @@ public class RemoteChangeLogTransaction, R extends public CompletionStage getAsync(K key) { var updater = entityChanges.get(key); if (updater != null) { - return updater.isDeleted() ? CompletableFutures.completedNull() : CompletableFuture.completedFuture(updater); + return updater.isInvalid() ? CompletableFutures.completedNull() : CompletableFuture.completedFuture(updater); } return getCache().getWithMetadataAsync(key).thenApply(e -> onEntityFromCache(key, e)); } @@ -189,7 +189,7 @@ public class RemoteChangeLogTransaction, R extends } var updater = factory.wrapFromCache(key, entity); entityChanges.put(key, updater); - return updater.isDeleted() ? null : updater; + return updater.isInvalid() ? null : updater; } @SuppressWarnings("unchecked") diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java index 35b1a2f9a3d..c4b7a839036 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaUserSessionPersisterProvider.java @@ -17,9 +17,13 @@ package org.keycloak.models.jpa.session; +import org.hibernate.jpa.AvailableHints; import org.jboss.logging.Logger; import org.keycloak.common.util.MultiSiteUtils; import org.keycloak.common.util.Time; +import org.keycloak.events.Details; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -259,19 +263,32 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv logger.tracef("Trigger removing expired user sessions for realm '%s'", realm.getName()); - int cs = em.createNamedQuery("deleteExpiredClientSessions") + TypedQuery query = em.createNamedQuery("findExpiredUserSessions", Object[].class) .setParameter("realmId", realm.getId()) .setParameter("lastSessionRefresh", expired) .setParameter("offline", offlineStr) - .executeUpdate(); + .setHint(AvailableHints.HINT_READ_ONLY, true); - int us = em.createNamedQuery("deleteExpiredUserSessions") - .setParameter("realmId", realm.getId()) - .setParameter("lastSessionRefresh", expired) - .setParameter("offline", offlineStr) - .executeUpdate(); + var expiredSessions = query.getResultStream() + .map(JpaUserSessionPersisterProvider::userSessionAndUserProjection) + .toList(); + + sendExpirationEvents(realm, expiredSessions); + + StreamsUtil.chunkedStream(expiredSessions.stream().map(UserSessionAndUser::userSessionId), 100).forEach(chunk -> { + // SQL databases only allow a limited number of items in an IN clause. While PostgreSQL allows possibly 32k, we are not sure about the rest + int cs = em.createNamedQuery("deleteClientSessionsByUserSessions") + .setParameter("userSessionId", chunk) + .setParameter("offline", offlineStr) + .executeUpdate(); + + int us = em.createNamedQuery("deleteUserSessions") + .setParameter("userSessionId", chunk) + .setParameter("offline", offlineStr) + .executeUpdate(); + logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName()); + }); - logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName()); } @Override @@ -302,12 +319,12 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv String offlineStr = offlineToString(offline); - TypedQuery userSessionQuery = em.createNamedQuery("findUserSession", PersistentUserSessionEntity.class); - userSessionQuery.setParameter("realmId", realm.getId()); - userSessionQuery.setParameter("offline", offlineStr); - userSessionQuery.setParameter("userSessionId", userSessionId); - userSessionQuery.setParameter("lastSessionRefresh", calculateOldestSessionTime(realm, offline)); - userSessionQuery.setMaxResults(1); + TypedQuery userSessionQuery = em.createNamedQuery("findUserSession", PersistentUserSessionEntity.class) + .setParameter("realmId", realm.getId()) + .setParameter("offline", offlineStr) + .setParameter("userSessionId", userSessionId) + .setParameter("lastSessionRefresh", calculateOldestSessionTime(realm, offline)) + .setMaxResults(1); return handleSingleQuery(userSessionQuery, offlineStr); } @@ -740,7 +757,26 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv ); } + private void sendExpirationEvents(RealmModel realm, List expiredSessions) { + expiredSessions.forEach(sessionAndUser -> new EventBuilder(realm, session) + .user(sessionAndUser.userId()) + .session(sessionAndUser.userSessionId()) + .event(EventType.USER_SESSION_DELETED) + .detail(Details.REASON, Details.EXPIRED_DETAIL) + .success()); + } + private static boolean hasClient(PersistentAuthenticatedClientSessionAdapter clientSession) { return clientSession.getClient() != null; } + + private record UserSessionAndUser(String userSessionId, String userId) { + } + + private static UserSessionAndUser userSessionAndUserProjection(Object[] projection) { + assert projection.length == 2; + assert projection[0] != null; + assert projection[1] != null; + return new UserSessionAndUser(String.valueOf(projection[0]), String.valueOf(projection[1])); + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java index 44856a9e42b..ee29097700c 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentClientSessionEntity.java @@ -28,6 +28,7 @@ import jakarta.persistence.Version; import org.hibernate.annotations.DynamicUpdate; import java.io.Serializable; +import java.util.Objects; /** * @author Marek Posolda @@ -39,6 +40,8 @@ import java.io.Serializable; @NamedQuery(name="deleteClientSessionsByClientStorageProvider", query="delete from PersistentClientSessionEntity sess where sess.clientStorageProvider = :clientStorageProvider"), @NamedQuery(name="deleteClientSessionsByUser", query="delete from PersistentClientSessionEntity sess where sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.userId = :userId)"), @NamedQuery(name="deleteClientSessionsByUserSession", query="delete from PersistentClientSessionEntity sess where sess.userSessionId = :userSessionId and sess.offline = :offline"), + @NamedQuery(name="deleteClientSessionsByUserSessions", query="delete from PersistentClientSessionEntity sess where sess.userSessionId in (:userSessionId) and sess.offline = :offline"), + // The query "deleteExpiredClientSessions" is deprecated (since 26.5) and may be removed in the future. @NamedQuery(name="deleteExpiredClientSessions", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId AND u.offline = :offline AND u.lastSessionRefresh < :lastSessionRefresh)"), @NamedQuery(name="deleteClientSessionsByRealmSessionType", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId and u.offline = :offline)"), @NamedQuery(name="findClientSessionsByUserSession", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline"), @@ -191,13 +194,11 @@ public class PersistentClientSessionEntity { Key key = (Key) o; - if (this.userSessionId != null ? !this.userSessionId.equals(key.userSessionId) : key.userSessionId != null) return false; - if (this.clientId != null ? !this.clientId.equals(key.clientId) : key.clientId != null) return false; - if (this.externalClientId != null ? !this.externalClientId.equals(key.externalClientId) : key.externalClientId != null) return false; - if (this.clientStorageProvider != null ? !this.clientStorageProvider.equals(key.clientStorageProvider) : key.clientStorageProvider != null) return false; - if (this.offline != null ? !this.offline.equals(key.offline) : key.offline != null) return false; - - return true; + return Objects.equals(this.userSessionId, key.userSessionId) && + Objects.equals(this.clientId, key.clientId) && + Objects.equals(this.externalClientId, key.externalClientId) && + Objects.equals(this.clientStorageProvider, key.clientStorageProvider) && + Objects.equals(this.offline, key.offline); } @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java index 8bfbccc7063..ca74e058e50 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/PersistentUserSessionEntity.java @@ -29,6 +29,7 @@ import jakarta.persistence.NamedQueries; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import java.io.Serializable; +import java.util.Objects; /** * @author Marek Posolda @@ -37,7 +38,10 @@ import java.io.Serializable; @NamedQuery(name="deleteUserSessionsByRealm", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId"), @NamedQuery(name="deleteUserSessionsByRealmSessionType", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId and sess.offline = :offline"), @NamedQuery(name="deleteUserSessionsByUser", query="delete from PersistentUserSessionEntity sess where sess.userId = :userId"), + // The query "deleteExpiredUserSessions" is deprecated (since 26.5) and may be removed in the future. @NamedQuery(name="deleteExpiredUserSessions", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"), + @NamedQuery(name="deleteUserSessions", query="delete from PersistentUserSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (:userSessionId)"), + @NamedQuery(name="findExpiredUserSessions", query="select sess.userSessionId, sess.userId from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"), @NamedQuery(name="updateUserSessionLastSessionRefresh", query="update PersistentUserSessionEntity sess set lastSessionRefresh = :lastSessionRefresh where sess.realmId = :realmId" + " AND sess.offline = :offline AND sess.userSessionId IN (:userSessionIds)"), @NamedQuery(name="findUserSessionsCount", query="select count(sess) from PersistentUserSessionEntity sess where sess.offline = :offline"), @@ -192,10 +196,8 @@ public class PersistentUserSessionEntity { Key key = (Key) o; - if (this.userSessionId != null ? !this.userSessionId.equals(key.userSessionId) : key.userSessionId != null) return false; - if (this.offline != null ? !this.offline.equals(key.offline) : key.offline != null) return false; - - return true; + return Objects.equals(this.userSessionId, key.userSessionId) && + Objects.equals(this.offline, key.offline); } @Override diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/EventOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/EventOptions.java index 02f4c8c1b15..ca61130a373 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/EventOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/EventOptions.java @@ -36,7 +36,7 @@ public class EventOptions { "execute_actions", "execute_action_token", "client_info", "client_register", "client_update", "client_delete", "client_initiated_account_linking", "token_exchange", "oauth2_device_auth", "oauth2_device_verify_user_code", "oauth2_device_code_to_token", "authreqid_to_token", "permission_token", "delete_account", "pushed_authorization_request", "user_disabled_by_permanent_lockout", "user_disabled_by_temporary_lockout", "oauth2_extension_grant", "federated_identity_override_link", "update_credential", "remove_credential", - "invite_org", "remove_totp", "update_totp", "update_password")); + "invite_org", "remove_totp", "update_totp", "update_password", "user_session_deleted")); events.sort(String::compareToIgnoreCase); return events; } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java index 2f7c790eb80..b5affb53ce8 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java @@ -211,7 +211,7 @@ public class HelpCommandDistTest { // normalize the output to prevent changes around the feature toggles or events to mark the output to differ String output = cliResult.getOutput() .replaceAll("((Disables|Enables) a set of one or more features. Possible values are: )[^.]{30,}", "$1<...>") - .replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.(Available|only|when|user|event|metrics|are|enabled.| )*", "$1<...>"); + .replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.[^.]*.", "$1<...>"); String osName = System.getProperty("os.name"); if(osName.toLowerCase(Locale.ROOT).contains("windows")) { diff --git a/server-spi-private/src/main/java/org/keycloak/events/Details.java b/server-spi-private/src/main/java/org/keycloak/events/Details.java index 1c8074f551b..69b59f43089 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Details.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Details.java @@ -114,4 +114,6 @@ public interface Details { String CLIENT_POLICY_ERROR = "client_policy_error"; String CLIENT_POLICY_ERROR_DETAIL = "client_policy_error_detail"; + + String EXPIRED_DETAIL = "expired"; } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 882816ce67b..bab264f62bb 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -18,7 +18,6 @@ package org.keycloak.events; import java.util.Map; -import java.util.Objects; import org.keycloak.util.EnumWithStableIndex; /** @@ -185,14 +184,17 @@ public enum EventType implements EnumWithStableIndex { REMOVE_CREDENTIAL_ERROR(0x10000 + REMOVE_CREDENTIAL.getStableIndex(), true), INVITE_ORG(60, true), - INVITE_ORG_ERROR(0x10000 + INVITE_ORG.getStableIndex(), true); + INVITE_ORG_ERROR(0x10000 + INVITE_ORG.getStableIndex(), true), + + USER_SESSION_DELETED(61, false), + USER_SESSION_DELETED_ERROR(0x10000 + USER_SESSION_DELETED.getStableIndex(), false), + ; private final int stableIndex; private final boolean saveByDefault; private static final Map BY_ID = EnumWithStableIndex.getReverseIndex(values()); EventType(int stableIndex, boolean saveByDefault) { - Objects.requireNonNull(stableIndex); this.stableIndex = stableIndex; this.saveByDefault = saveByDefault; } @@ -204,7 +206,6 @@ public enum EventType implements EnumWithStableIndex { /** * Determines whether this event is stored when the admin has not set a specific set of event types to save. - * @return */ public boolean isSaveByDefault() { return saveByDefault; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java index 4469ed70765..626714a4923 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -29,6 +29,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.common.util.Time; import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.protocol.oidc.grants.AuthorizationCodeGrantTypeFactory; import org.keycloak.protocol.oidc.grants.RefreshTokenGrantTypeFactory; @@ -68,7 +69,7 @@ public class AssertEvents implements TestRule { public static final String DEFAULT_REDIRECT_URI = getAuthServerContextRoot() + "/auth/realms/master/app/auth"; - private AbstractKeycloakTest context; + private final AbstractKeycloakTest context; public AssertEvents(AbstractKeycloakTest ctx) { context = ctx; @@ -188,6 +189,44 @@ public class AssertEvents implements TestRule { .session(sessionId); } + public ExpectedEvent expectSessionExpired(String sessionId, String userId) { + return expect(EventType.USER_SESSION_DELETED) + .session(sessionId) + .user(userId) + .detail(Details.REASON, Details.EXPIRED_DETAIL) + .client((String) null) + .ipAddress((String) null); + } + + public void assertRefreshTokenErrorAndMaybeSessionExpired(String sessionId, String userId, String clientId) { + // events can be in any order + ExpectedEvent expired = expectSessionExpired(sessionId, userId); + ExpectedEvent refresh = expect(EventType.REFRESH_TOKEN) + .session(sessionId) + .client(clientId) + .error(Errors.INVALID_TOKEN) + .user((String) null); + EventRepresentation e = poll(5); + if (e.getType().equals(EventType.USER_SESSION_DELETED.name())) { + // if we get an expiration event, we must receive the refresh token error event. + expired.assertEvent(e); + refresh.assertEvent(); + return; + } + if (e.getType().equals(EventType.REFRESH_TOKEN_ERROR.name())) { + refresh.assertEvent(e); + // The session expiration event is optional. + // With volatile session send an event because Infinispan sends events on reads. + // With persistent session only sends the events during the periodic cleanup task. + e = fetchNextEvent(); + if (e != null) { + expired.assertEvent(e); + } + return; + } + Assert.fail("Unexpected event type: " + e.getType()); + } + public ExpectedEvent expectLogout(String sessionId) { return expect(EventType.LOGOUT) .detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI)) @@ -271,7 +310,7 @@ public class AssertEvents implements TestRule { } public class ExpectedEvent { - private EventRepresentation expected = new EventRepresentation(); + private final EventRepresentation expected = new EventRepresentation(); private Matcher realmId; private Matcher userId; private Matcher sessionId; @@ -440,23 +479,14 @@ public class AssertEvents implements TestRule { assertThat("session ID", actual.getSessionId(), is(sessionId)); if (details == null || details.isEmpty()) { -// Assert.assertNull(actual.getDetails()); - } else { - Assert.assertNotNull(actual.getDetails()); - for (Map.Entry> d : details.entrySet()) { - String actualValue = actual.getDetails().get(d.getKey()); - - assertThat("Unexpected value for " + d.getKey(), actualValue, d.getValue()); - } - /* - for (String k : actual.getDetails().keySet()) { - if (!details.containsKey(k)) { - Assert.fail(k + " was not expected"); - } - } - */ + return actual; } + Assert.assertNotNull(actual.getDetails()); + for (Map.Entry> d : details.entrySet()) { + String actualValue = actual.getDetails().get(d.getKey()); + assertThat("Unexpected value for " + d.getKey(), actualValue, d.getValue()); + } return actual; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index 2090b371b17..4bf8a060cf6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -82,8 +82,6 @@ import jakarta.ws.rs.NotFoundException; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.LinkedHashMap; -import java.util.ArrayList; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -128,7 +126,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { @Before public void clientConfiguration() { userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(); - oauth.clientId("test-app"); + oauth.client("test-app"); } @Override @@ -174,13 +172,11 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenDisabledForClient() throws Exception { + public void offlineTokenDisabledForClient() { // Remove offline-access scope from client - ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream().filter((ClientScopeRepresentation clientScope) -> { - - return OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName()); - - }).findFirst().get(); + ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream() + .filter((ClientScopeRepresentation clientScope) -> OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName())) + .findFirst().get(); ClientManager.realm(adminClient.realm("test")).clientId("offline-client") .fullScopeAllowed(false) @@ -201,7 +197,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenUserNotAllowed() throws Exception { + public void offlineTokenUserNotAllowed() { String userId = findUserByUsername(adminClient.realm("test"), "keycloak-user@localhost").getId(); oauth.scope(OAuth2Constants.OFFLINE_ACCESS); @@ -234,7 +230,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenBrowserFlow() throws Exception { + public void offlineTokenBrowserFlow() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -264,9 +260,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest { Assert.assertNull(offlineToken.getExp()); AccessTokenContext ctx = testingClient.testing("test").getTokenContext(token.getId()); - Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE); - Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR); - Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.AUTHORIZATION_CODE); + Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); + Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); + Assert.assertEquals(OAuth2Constants.AUTHORIZATION_CODE, ctx.getGrantType()); assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); @@ -283,18 +279,13 @@ public class OfflineTokenTest extends AbstractKeycloakTest { Assert.assertEquals(400, response.getStatusCode()); assertEquals("invalid_grant", response.getError()); - events.expectRefresh(offlineToken.getId(), newRefreshToken.getSessionState()) - .client("offline-client") - .user((String) null) - .error(Errors.INVALID_TOKEN) - .clearDetails() - .assertEvent(); + events.assertRefreshTokenErrorAndMaybeSessionExpired(newRefreshToken.getSessionId(), loginEvent.getUserId(), "offline-client"); setTimeOffset(0); } @Test - public void onlineOfflineTokenBrowserFlow() throws Exception { + public void onlineOfflineTokenBrowserFlow() { // request an online token for the client oauth.scope(null); oauth.client("offline-client", "secret1"); @@ -373,9 +364,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest { AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); Assert.assertEquals(200, response.getStatusCode()); AccessTokenContext ctx = testingClient.testing("test").getTokenContext(refreshedToken.getId()); - Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE); - Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR); - Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.REFRESH_TOKEN); + Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); + Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); + Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN, ctx.getGrantType()); // Assert new refreshToken in the response String newRefreshToken = response.getRefreshToken(); @@ -419,7 +410,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenDirectGrantFlow() throws Exception { + public void offlineTokenDirectGrantFlow() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); @@ -456,7 +447,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() throws Exception { + public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() { RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true); oauth.scope(OAuth2Constants.OFFLINE_ACCESS); @@ -470,7 +461,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { events.expectLogin() .client("offline-client") .user(userId) - .session(token.getSessionState()) + .session(token.getSessionId()) .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) @@ -484,13 +475,13 @@ public class OfflineTokenTest extends AbstractKeycloakTest { Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertNull(offlineToken.getExp()); - String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId); + String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); // Assert second refresh with same refresh token will fail AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString); Assert.assertEquals(400, response.getStatusCode()); - events.expectRefresh(offlineToken.getId(), token.getSessionState()) + events.expectRefresh(offlineToken.getId(), token.getSessionId()) .client("offline-client") .user((String) null) .error(Errors.INVALID_TOKEN) @@ -500,7 +491,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { // Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token) AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2); Assert.assertEquals(400, response2.getStatusCode()); - events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionState()) + events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionId()) .client("offline-client") .user((String) null) .error(Errors.INVALID_TOKEN) @@ -511,7 +502,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenServiceAccountFlow() throws Exception { + public void offlineTokenServiceAccountFlow() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); @@ -564,7 +555,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenAllowedWithCompositeRole() throws Exception { + public void offlineTokenAllowedWithCompositeRole() { RealmResource appRealm = adminClient.realm("test"); UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"), @@ -591,10 +582,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest { /** * KEYCLOAK-4201 * - * @throws Exception */ @Test - public void offlineTokenAdminRESTAccess() throws Exception { + public void offlineTokenAdminRESTAccess() { // Grant "view-realm" role to user RealmResource appRealm = adminClient.realm("test"); ClientResource realmMgmt = ApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID); @@ -632,7 +622,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { // KEYCLOAK-4525 @Test - public void offlineTokenRemoveClientWithTokens() throws Exception { + public void offlineTokenRemoveClientWithTokens() { // Create new client RealmResource appRealm = adminClient.realm("test"); @@ -641,7 +631,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { .directAccessGrants() .secret("secret1").build(); - appRealm.clients().create(clientRep); + appRealm.clients().create(clientRep).close(); // Direct grant login requesting offline token oauth.scope(OAuth2Constants.OFFLINE_ACCESS); @@ -655,7 +645,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { events.expectLogin() .client("offline-client-2") .user(userId) - .session(token.getSessionState()) + .session(token.getSessionId()) .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) @@ -673,7 +663,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest { for (Map consent : userConsents) { if (consent.get("clientId").equals("offline-client-2")) { clientId2 = String.valueOf(consent.get("clientId")); - offlineAdditionalGrant = String.valueOf(((LinkedHashMap) ((ArrayList) consent.get("additionalGrants")).get(0)).get("key")); + //noinspection unchecked + offlineAdditionalGrant = String.valueOf((((List>) consent.get("additionalGrants")).get(0)).get("key")); } } @@ -691,12 +682,12 @@ public class OfflineTokenTest extends AbstractKeycloakTest { UserResource user = ApiUtil.findUserByUsernameId(appRealm, "test-user@localhost"); List> consents = user.getConsents(); for (Map consent : consents) { - assertNotEquals(consent.get("clientId"), "offline-client-2"); + assertNotEquals("offline-client-2", consent.get("clientId")); } } @Test - public void offlineTokenLogout() throws Exception { + public void offlineTokenLogout() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password"); @@ -713,7 +704,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void onlineOfflineTokenLogout() throws Exception { + public void onlineOfflineTokenLogout() { oauth.client("offline-client", "secret1"); // create online session @@ -747,7 +738,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void browserOfflineTokenLogoutFollowedByLoginSameSession() throws Exception { + public void browserOfflineTokenLogoutFollowedByLoginSameSession() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -777,7 +768,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { assertNull(offlineToken.getExp()); String offlineUserSessionId = testingClient.server().fetch((KeycloakSession session) -> - session.sessions().getOfflineUserSession(session.realms().getRealmByName("test"), offlineToken.getSessionState()).getId(), String.class); + session.sessions().getOfflineUserSession(session.realms().getRealmByName("test"), offlineToken.getSessionId()).getId(), String.class); // logout offline session LogoutResponse logoutResponse = oauth.doLogout(offlineTokenString); @@ -804,7 +795,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { codeId = loginEvent.getDetails().get(Details.CODE_ID); - events.expectCodeToToken(codeId, offlineToken2.getSessionState()) + events.expectCodeToToken(codeId, offlineToken2.getSessionId()) .client("offline-client") .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .assertEvent(); @@ -813,7 +804,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { Assert.assertNull(offlineToken.getExp()); // Assert session changed - assertNotEquals(offlineToken.getSessionState(), offlineToken2.getSessionState()); + assertNotEquals(offlineToken.getSessionId(), offlineToken2.getSessionId()); } // KEYCLOAK-7688 Offline Session Max for Offline Token @@ -843,7 +834,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenBrowserFlowMaxLifespanExpired() throws Exception { + public void offlineTokenBrowserFlowMaxLifespanExpired() { // expect that offline session expired by max lifespan final int MAX_LIFESPAN = 3600; final int IDLE_LIFESPAN = 6000; @@ -851,7 +842,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineTokenBrowserFlowIdleTimeExpired() throws Exception { + public void offlineTokenBrowserFlowIdleTimeExpired() { // expect that offline session expired by idle time final int MAX_LIFESPAN = 3000; final int IDLE_LIFESPAN = 600; @@ -868,8 +859,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest { getTestingClient().testing().setTestingInfinispanTimeService(); - int prev[] = null; - try (RealmAttributeUpdater rau = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) { + int[] prev = null; + try (RealmAttributeUpdater ignored = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) { prev = changeOfflineSessionSettings(true, MAX_LIFESPAN, IDLE_LIFESPAN, 0, 0); // Step 1 - online login with "tets-app" @@ -917,8 +908,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } } - private RefreshToken assertOfflineToken(AccessTokenResponse tokenResponse) { - return assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE); + private void assertOfflineToken(AccessTokenResponse tokenResponse) { + assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE); } // Asserts that refresh token in the tokenResponse is of the given type. Return parsed token @@ -977,7 +968,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { }, Integer.class); } - private void removeClientSessionStartedAtNote(final String userSessionId, final String clientId, final String clientSessionId) { + private void removeClientSessionStartedAtNote(final String userSessionId, final String clientId) { testingClient.server().run(session -> { RealmModel realmModel = session.realms().getRealmByName("test"); session.getContext().setRealm(realmModel); @@ -1027,9 +1018,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest { setTimeOffset(offsetHalf); tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString); - AccessToken refreshedToken = oauth.verifyToken(tokenResponse.getAccessToken()); + oauth.verifyToken(tokenResponse.getAccessToken()); offlineTokenString = tokenResponse.getRefreshToken(); - offlineToken = oauth.parseRefreshToken(offlineTokenString); + oauth.parseRefreshToken(offlineTokenString); Assert.assertEquals(200, tokenResponse.getStatusCode()); assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); @@ -1064,7 +1055,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { oauth.client("offline-client", "secret1"); AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - JWSHeader header = null; + JWSHeader header; String idToken = tokenResponse.getIdToken(); String accessToken = tokenResponse.getAccessToken(); String refreshToken = tokenResponse.getRefreshToken(); @@ -1094,7 +1085,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { events.expectClientLogin() .client("offline-client") .user(serviceAccountUserId) - .session(token.getSessionState()) + .session(token.getSessionId()) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) @@ -1104,7 +1095,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertNull(offlineToken.getExp()); - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId); + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); // Now retrieve another offline token and decode that previous offline token is still valid tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); @@ -1116,7 +1107,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { events.expectClientLogin() .client("offline-client") .user(serviceAccountUserId) - .session(token2.getSessionState()) + .session(token2.getSessionId()) .detail(Details.TOKEN_ID, token2.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) @@ -1124,9 +1115,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest { .assertEvent(); // Refresh with both offline tokens is fine - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId); - testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionState(), serviceAccountUserId); - + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); + testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId); } private void offlineTokenRequestWithScopeParameter(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { @@ -1136,7 +1126,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { oauth.client("offline-client", "secret1"); AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - JWSHeader header = null; + JWSHeader header; String idToken = tokenResponse.getIdToken(); String accessToken = tokenResponse.getAccessToken(); String refreshToken = tokenResponse.getRefreshToken(); @@ -1166,7 +1156,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { events.expectClientLogin() .client("offline-client") .user(serviceAccountUserId) - .session(token.getSessionState()) + .session(token.getSessionId()) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) @@ -1178,7 +1168,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void refreshTokenUserClientMaxLifespanSmallerThanSession() throws Exception { + public void refreshTokenUserClientMaxLifespanSmallerThanSession() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -1225,7 +1215,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void refreshTokenUserClientMaxLifespanGreaterThanSession() throws Exception { + public void refreshTokenUserClientMaxLifespanGreaterThanSession() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -1272,7 +1262,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() throws Exception { + public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -1306,7 +1296,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { assertEquals(400, tokenResponse.getStatusCode()); assertNull(tokenResponse.getAccessToken()); assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).session(sessionId).client("offline-client").error(Errors.INVALID_TOKEN).user((String) null).assertEvent(); + events.assertRefreshTokenErrorAndMaybeSessionExpired(sessionId, loginEvent.getUserId(), "offline-client"); assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); } finally { changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); @@ -1317,7 +1307,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() throws Exception { + public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.client("offline-client", "secret1"); oauth.redirectUri(offlineClientAppUri); @@ -1387,7 +1377,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); JsonNode jsonNode = oauth.doIntrospectionAccessTokenRequest(tokenResponse.getAccessToken()).asJsonNode(); - Assert.assertEquals(true, jsonNode.get("active").asBoolean()); + assertTrue(jsonNode.get("active").asBoolean()); Assert.assertEquals("test-user@localhost", jsonNode.get("email").asText()); assertThat(jsonNode.get("exp").asInt() - getCurrentTime(), allOf(greaterThanOrEqualTo(59), lessThanOrEqualTo(60))); @@ -1399,7 +1389,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void testClientOfflineSessionMaxLifespan() throws Exception { + public void testClientOfflineSessionMaxLifespan() { ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"); ClientRepresentation clientRepresentation = client.toRepresentation(); @@ -1451,7 +1441,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void testClientOfflineSessionIdleTimeout() throws Exception { + public void testClientOfflineSessionIdleTimeout() { ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"); ClientRepresentation clientRepresentation = client.toRepresentation(); @@ -1529,7 +1519,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { } @Test - public void offlineRefreshWhenNoStartedAtClientNote() throws Exception { + public void offlineRefreshWhenNoStartedAtClientNote() { int prevOfflineSession[] = null; try { prevOfflineSession = changeOfflineSessionSettings(true, 3600, 3600, 0, 0); @@ -1548,8 +1538,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { .assertEvent(); // remove the started notes that can be missed in previous versions - String clientSessionId = getOfflineClientSessionUuid(loginEvent.getSessionId(), loginEvent.getClientId()); - removeClientSessionStartedAtNote(loginEvent.getSessionId(), loginEvent.getClientId(), clientSessionId); + removeClientSessionStartedAtNote(loginEvent.getSessionId(), loginEvent.getClientId()); // check refresh is successful response = oauth.doRefreshTokenRequest(response.getRefreshToken()); @@ -1614,7 +1603,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest { .assertEvent(); // remove offline scope from the client and perform a second refresh - try (ClientAttributeUpdater updater = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client") + try (ClientAttributeUpdater ignored = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client") .removeOptionalClientScope("offline_access").update()) { introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 71104512164..8990fc26a9e 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -38,9 +38,9 @@ import org.keycloak.common.enums.SslRequired; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.cookie.CookieType; import org.keycloak.crypto.Algorithm; -import org.keycloak.events.EventType; import org.keycloak.events.Details; import org.keycloak.events.Errors; +import org.keycloak.events.EventType; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.AuthenticatedClientSessionModel; @@ -76,8 +76,8 @@ import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.ClientManager; -import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmManager; import org.keycloak.testsuite.util.TokenSignatureUtil; @@ -85,7 +85,7 @@ import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserInfoClientUtil; import org.keycloak.testsuite.util.UserManager; import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.util.BrowserTabUtil; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.util.BasicAuthHelper; import org.openqa.selenium.Cookie; @@ -101,26 +101,28 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.lessThan; import static org.hamcrest.Matchers.lessThanOrEqualTo; -import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT; import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN; -import static org.keycloak.testsuite.Assert.assertExpiration; import static org.keycloak.testsuite.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.Assert.assertExpiration; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; +import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.getHttpAuthServerContextRoot; import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED; import static org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT; -import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.getHttpAuthServerContextRoot; /** * @author Stian Thorgersen @@ -176,16 +178,13 @@ public class RefreshTokenTest extends AbstractKeycloakTest { URI uri = OIDCLoginProtocolService.tokenUrl(builder).build("test"); WebTarget target = client.target(uri); - org.keycloak.representations.AccessTokenResponse tokenResponse = null; - { - String header = BasicAuthHelper.createHeader("test-app", "password"); - Form form = new Form(); - Response response = target.request() - .header(HttpHeaders.AUTHORIZATION, header) - .post(Entity.form(form)); - assertEquals(400, response.getStatus()); - response.close(); - } + String header = BasicAuthHelper.createHeader("test-app", "password"); + Form form = new Form(); + Response response = target.request() + .header(HttpHeaders.AUTHORIZATION, header) + .post(Entity.form(form)); + assertEquals(400, response.getStatus()); + response.close(); events.clear(); } @@ -256,7 +255,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { long actual = refreshToken.getExp() - getCurrentTime(); assertThat(actual, allOf(greaterThanOrEqualTo(1799L - ALLOWED_CLOCK_SKEW), lessThanOrEqualTo(1800L + ALLOWED_CLOCK_SKEW))); - assertEquals(sessionId, refreshToken.getSessionState()); + assertEquals(sessionId, refreshToken.getSessionId()); assertNull(refreshToken.getNonce()); AccessTokenResponse response = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); @@ -265,8 +264,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertEquals(200, response.getStatusCode()); - assertEquals(sessionId, refreshedToken.getSessionState()); - assertEquals(sessionId, refreshedRefreshToken.getSessionState()); + assertEquals(sessionId, refreshedToken.getSessionId()); + assertEquals(sessionId, refreshedRefreshToken.getSessionId()); assertThat(response.getExpiresIn(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300))); assertThat(refreshedToken.getExp() - getCurrentTime(), allOf(greaterThanOrEqualTo(250L - ALLOWED_CLOCK_SKEW), lessThanOrEqualTo(300L + ALLOWED_CLOCK_SKEW))); @@ -303,7 +302,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertNotNull(response.getRefreshToken()); refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); - assertEquals(sessionId, refreshToken.getSessionState()); + assertEquals(sessionId, refreshToken.getSessionId()); assertNull(refreshToken.getNonce()); } @@ -360,32 +359,30 @@ public class RefreshTokenTest extends AbstractKeycloakTest { // Test when neither client nor user session is in the cache testingClient.server().run(session -> { - session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).clear(); - session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).clear(); + session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).clear(); + session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).clear(); }); response = oauth.doRefreshTokenRequest(refreshTokenString); Assert.assertEquals(200, response.getStatusCode()); testingClient.server().run(session -> { - assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).size(), + assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(), greaterThan(0)); - assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).size(), + assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).size(), greaterThan(0)); }); // Test is only the client session is missing - testingClient.server().run(session -> { - session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).clear(); - }); + testingClient.server().run(session -> session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).clear()); response = oauth.doRefreshTokenRequest(refreshTokenString); Assert.assertEquals(200, response.getStatusCode()); testingClient.server().run(session -> { - assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).size(), + assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(), greaterThan(0)); - assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).size(), + assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).size(), greaterThan(0)); }); @@ -428,20 +425,20 @@ public class RefreshTokenTest extends AbstractKeycloakTest { .clientId("public-client") .redirectUris("*") .publicClient() - .build()); + .build()).close(); realmResource.users() .create(UserBuilder.create().username("alice") .firstName("alice") .lastName("alice") .email("alice@keycloak.org") - .password("alice").addRoles("offline_access").build()); + .password("alice").addRoles("offline_access").build()).close(); realmResource.users() .create(UserBuilder.create().username("bob") .firstName("bob") .lastName("bob") .email("bob@keycloak.org") - .password("bob").addRoles("offline_access").build()); + .password("bob").addRoles("offline_access").build()).close(); oauth.realm(realmName); oauth.client("public-client"); @@ -493,12 +490,12 @@ public class RefreshTokenTest extends AbstractKeycloakTest { .clientId("public-client") .redirectUris("*") .publicClient() - .build()); + .build()).close(); realmResource.users() - .create(UserBuilder.create().username("alice").password("alice").addRoles("offline_access").build()); + .create(UserBuilder.create().username("alice").password("alice").addRoles("offline_access").build()).close(); realmResource.users() - .create(UserBuilder.create().username("bob").password("bob").addRoles("offline_access").build()); + .create(UserBuilder.create().username("bob").password("bob").addRoles("offline_access").build()).close(); oauth.realm(realmName); oauth.client("public-client"); @@ -509,7 +506,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { oauth.fillLoginForm("alice", "alice"); - String aliceCode = oauth.parseLoginResponse().getCode(); + oauth.parseLoginResponse().getCode(); // WebClient webClient = DroneHtmlUnitDriver.class.cast(driver).getWebClient(); // webClient.getCookieManager().clearCookies(); driver.manage().deleteAllCookies(); @@ -585,7 +582,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { try { oauth.doLogin("test-user@localhost", "password"); - String code = oauth.parseLoginResponse().getCode(); + oauth.parseLoginResponse().getCode(); String optionalScope = "phone address"; oauth.scope(optionalScope); @@ -999,7 +996,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { code = oauth.parseLoginResponse().getCode(); AccessTokenResponse response4 = oauth.doAccessTokenRequest(code); - RefreshToken refreshToken4 = oauth.parseRefreshToken(response4.getRefreshToken()); + oauth.parseRefreshToken(response4.getRefreshToken()); events.expectCodeToToken(codeId, sessionId).assertEvent(); // Client sessions should be available again now after re-authentication @@ -1121,7 +1118,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertFalse(loginPage.isCurrent()); - AccessTokenResponse tokenResponse2 = null; + AccessTokenResponse tokenResponse2; String code = oauth.parseLoginResponse().getCode(); tokenResponse2 = oauth.doAccessTokenRequest(code); @@ -1155,7 +1152,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertFalse(loginPage.isCurrent()); - AccessTokenResponse tokenResponse2 = null; + AccessTokenResponse tokenResponse2; String code = oauth.parseLoginResponse().getCode(); tokenResponse2 = oauth.doAccessTokenRequest(code); @@ -1188,7 +1185,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertFalse(loginPage.isCurrent()); - AccessTokenResponse tokenResponse2 = null; + AccessTokenResponse tokenResponse2; String code = oauth.parseLoginResponse().getCode(); tokenResponse2 = oauth.doAccessTokenRequest(code); @@ -1231,8 +1228,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest { tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - AccessToken refreshedToken = oauth.verifyToken(tokenResponse.getAccessToken()); - RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(tokenResponse.getRefreshToken()); + oauth.verifyToken(tokenResponse.getAccessToken()); + oauth.parseRefreshToken(tokenResponse.getRefreshToken()); assertEquals(200, tokenResponse.getStatusCode()); @@ -1281,7 +1278,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { public void testUserSessionRefreshAndIdleRememberMe() throws Exception { RealmResource testRealm = adminClient.realm("test"); - try (Closeable realmUpdater = new RealmAttributeUpdater(testRealm) + try (Closeable ignored = new RealmAttributeUpdater(testRealm) .updateWith(r -> { r.setRememberMe(true); r.setSsoSessionIdleTimeoutRememberMe(500); @@ -1357,7 +1354,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { public void refreshTokenUserSessionMaxLifespan() throws Exception { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(3600); r.setSsoSessionIdleTimeout(7200); @@ -1384,7 +1381,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { events.expectRefresh(refreshId, sessionId).assertEvent(); setTimeOffset(3700); - oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); + oauth.parseRefreshToken(tokenResponse.getRefreshToken()); tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); assertEquals(400, tokenResponse.getStatusCode()); @@ -1403,7 +1400,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { public void refreshTokenUserClientMaxLifespanSmallerThanSession() throws Exception { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(3600); r.setSsoSessionIdleTimeout(7200); @@ -1469,7 +1466,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { public void refreshTokenUserClientMaxLifespanGreaterThanSession() throws Exception { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(3600); r.setSsoSessionIdleTimeout(7200); @@ -1516,7 +1513,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(7200); r.setSsoSessionIdleTimeout(7200); @@ -1545,7 +1542,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertEquals(400, tokenResponse.getStatusCode()); assertNull(tokenResponse.getAccessToken()); assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).error(Errors.INVALID_TOKEN).session(sessionId).user((String) null).assertEvent(); + events.assertRefreshTokenErrorAndMaybeSessionExpired(sessionId, loginEvent.getUserId(), loginEvent.getClientId()); assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); } finally { getTestingClient().testing().revertTestingInfinispanTimeService(); @@ -1559,7 +1556,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(7200); r.setSsoSessionIdleTimeout(7200); @@ -1624,7 +1621,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RealmResource realmResource = adminClient.realm("test"); getTestingClient().testing().setTestingInfinispanTimeService(); - try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource) + try (Closeable ignored = new RealmAttributeUpdater(realmResource) .updateWith(r -> { r.setSsoSessionMaxLifespan(7200); r.setSsoSessionIdleTimeout(7200); @@ -1684,7 +1681,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { RealmResource testRealm = adminClient.realm("test"); - try (Closeable realmUpdater = new RealmAttributeUpdater(testRealm) + try (Closeable ignored = new RealmAttributeUpdater(testRealm) .updateWith(r -> { r.setRememberMe(true); r.setSsoSessionMaxLifespanRememberMe(100); @@ -1855,8 +1852,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest { testingClient.server("test").run(session -> { InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); if (connections != null) { - Cache> sessionCache = connections.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME); - Cache> clientSessionCache = connections.getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME); + Cache> sessionCache = connections.getCache(USER_SESSION_CACHE_NAME); + Cache> clientSessionCache = connections.getCache(CLIENT_SESSION_CACHE_NAME); if (sessionCache != null) { sessionCache.clear(); } @@ -1891,8 +1888,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { @Test public void testCheckSsl() { - Client client = AdminClientUtil.createResteasyClient(); - try { + try (Client client = AdminClientUtil.createResteasyClient()) { UriBuilder builder = UriBuilder.fromUri(AUTH_SERVER_ROOT); URI grantUri = OIDCLoginProtocolService.tokenUrl(builder).build("test"); WebTarget grantTarget = client.target(grantUri); @@ -1900,7 +1896,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { URI uri = OIDCLoginProtocolService.tokenUrl(builder).build("test"); WebTarget refreshTarget = client.target(uri); - String refreshToken = null; + String refreshToken; { Response response = executeGrantAccessTokenRequest(grantTarget); assertEquals(200, response.getStatus()); @@ -1932,15 +1928,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest { } } - { - Response response = executeRefreshToken(refreshTarget, refreshToken); - assertEquals(200, response.getStatus()); - org.keycloak.representations.AccessTokenResponse tokenResponse = response.readEntity(org.keycloak.representations.AccessTokenResponse.class); - refreshToken = tokenResponse.getRefreshToken(); - response.close(); - } + Response response = executeRefreshToken(refreshTarget, refreshToken); + assertEquals(200, response.getStatus()); + response.readEntity(org.keycloak.representations.AccessTokenResponse.class); + response.close(); } finally { - client.close(); events.clear(); } @@ -1993,7 +1985,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { events.expectCodeToToken(codeId, sessionId).user(userId).assertEvent(); - adminClient.realm("test").users().delete(userId); + adminClient.realm("test").users().delete(userId).close(); response = oauth.doRefreshTokenRequest(refreshTokenString); assertEquals(400, response.getStatusCode()); @@ -2102,6 +2094,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { @Test // KEYCLOAK-17323 public void testRefreshTokenWhenClientSessionTimeoutPassedButRealmDidNot() { + //noinspection resource getCleanup() .addCleanup(new RealmAttributeUpdater(adminClient.realm("test")) .setSsoSessionIdleTimeout(2592000) // 30 Days @@ -2219,7 +2212,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { .post(Entity.form(form)); } - private void conductTokenRefreshRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { + private void conductTokenRefreshRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { try { // Realm setting is used for ID Token signature algorithm TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, expectedIdTokenAlg); @@ -2268,7 +2261,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertEquals("Bearer", tokenResponse.getTokenType()); - assertEquals(sessionId, refreshToken.getSessionState()); + assertEquals(sessionId, refreshToken.getSessionId()); AccessTokenResponse response = oauth.doRefreshTokenRequest(refreshTokenString); if (response.getError() != null || response.getErrorDescription() != null) { @@ -2280,8 +2273,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertEquals(200, response.getStatusCode()); - assertEquals(sessionId, refreshedToken.getSessionState()); - assertEquals(sessionId, refreshedRefreshToken.getSessionState()); + assertEquals(sessionId, refreshedToken.getSessionId()); + assertEquals(sessionId, refreshedRefreshToken.getSessionId()); Assert.assertNotEquals(token.getId(), refreshedToken.getId()); Assert.assertNotEquals(refreshToken.getId(), refreshedRefreshToken.getId()); diff --git a/testsuite/model/pom.xml b/testsuite/model/pom.xml index 8781d572396..eaf0d13616d 100644 --- a/testsuite/model/pom.xml +++ b/testsuite/model/pom.xml @@ -128,6 +128,12 @@ org.infinispan infinispan-remote-query-server + + + org.awaitility + awaitility + test + diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java new file mode 100644 index 00000000000..c21c4d846ce --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionExpirationTest.java @@ -0,0 +1,167 @@ +/* + * 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.testsuite.model.session; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.awaitility.Awaitility; +import org.infinispan.Cache; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.common.util.Time; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Event; +import org.keycloak.events.EventStoreProvider; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RealmProvider; +import org.keycloak.models.UserManager; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.testsuite.model.HotRodServerRule; +import org.keycloak.testsuite.model.KeycloakModelTest; +import org.keycloak.testsuite.model.RequireProvider; + +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME; +import static org.keycloak.models.utils.SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; +import static org.keycloak.testsuite.model.session.UserSessionPersisterProviderTest.createClients; +import static org.keycloak.testsuite.model.session.UserSessionPersisterProviderTest.createSessions; + +@RequireProvider(UserSessionPersisterProvider.class) +@RequireProvider(UserSessionProvider.class) +@RequireProvider(UserProvider.class) +@RequireProvider(RealmProvider.class) +@RequireProvider(EventStoreProvider.class) +public class UserSessionExpirationTest extends KeycloakModelTest { + + private static final int IDLE_TIMEOUT = 1800; + private static final int LIFESPAN_TIMEOUT = 36000; + + private String realmId; + + @Override + public void createEnvironment(KeycloakSession s) { + RealmModel realm = createRealm(s, "test"); + realm.setEventsEnabled(true); + realm.setEnabledEventTypes(Set.of(EventType.USER_SESSION_DELETED.name())); + s.getContext().setRealm(realm); + realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName())); + realm.setSsoSessionIdleTimeout(IDLE_TIMEOUT); + realm.setSsoSessionMaxLifespan(LIFESPAN_TIMEOUT); + this.realmId = realm.getId(); + + s.users().addUser(realm, "user1").setEmail("user1@localhost"); + s.users().addUser(realm, "user2").setEmail("user2@localhost"); + + createClients(s, realm); + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel realm = s.realms().getRealm(realmId); + s.getContext().setRealm(realm); + s.sessions().removeUserSessions(realm); + + UserModel user1 = s.users().getUserByUsername(realm, "user1"); + UserModel user2 = s.users().getUserByUsername(realm, "user2"); + + UserManager um = new UserManager(s); + if (user1 != null) { + um.removeUser(realm, user1); + } + if (user2 != null) { + um.removeUser(realm, user2); + } + + s.realms().removeRealm(realmId); + } + + @Test + public void testExpirationEvents() { + UserSessionModel[] userSessions = inComittedTransaction(session -> { + return createSessions(session, realmId); + }); + Map sessionIdAndUsers = Arrays.stream(userSessions) + .collect(Collectors.toUnmodifiableMap(UserSessionModel::getId, s -> s.getUser().getId())); + + inComittedTransaction(session -> { + // Time offset is automatically cleaned up in KeycloakModelTest.cleanEnvironment() + Time.setOffset(IDLE_TIMEOUT + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10); + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm); + + var hotRodServer = getParameters(HotRodServerRule.class).findFirst(); + if (hotRodServer.isEmpty()) { + InfinispanConnectionProvider provider = session.getProvider(InfinispanConnectionProvider.class); + processExpiration(provider.getCache(USER_SESSION_CACHE_NAME)); + processExpiration(provider.getCache(OFFLINE_USER_SESSION_CACHE_NAME)); + } else { + hotRodServer.get().streamCacheManagers() + .forEach(cacheManager -> { + processExpiration(cacheManager.getCache(USER_SESSION_CACHE_NAME)); + processExpiration(cacheManager.getCache(OFFLINE_USER_SESSION_CACHE_NAME)); + }); + } + + // Infinispan events are async, let's ensure it is stored in the database before proceed. + Awaitility.await().until(() -> eventsCount(session) == sessionIdAndUsers.size()); + }); + + inComittedTransaction(session -> { + RealmModel realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); + // user session id -> user id + Map eventsData = events(session); + Assert.assertEquals(sessionIdAndUsers, eventsData); + }); + } + + private static void processExpiration(Cache cache) { + cache.getAdvancedCache().getExpirationManager().processExpiration(); + } + + private static long eventsCount(KeycloakSession session) { + EventStoreProvider provider = session.getProvider(EventStoreProvider.class); + return provider.createQuery() + .type(EventType.USER_SESSION_DELETED) + .getResultStream() + .filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON))) + .count(); + } + + private static Map events(KeycloakSession session) { + EventStoreProvider provider = session.getProvider(EventStoreProvider.class); + return provider.createQuery() + .type(EventType.USER_SESSION_DELETED) + .getResultStream() + .filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON))) + .collect(Collectors.toUnmodifiableMap(Event::getSessionId, Event::getUserId)); + } + +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java index e52b897c391..f67e52b2c9f 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/session/UserSessionPersisterProviderTest.java @@ -557,13 +557,8 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { createClientSession(session, realmId, realm.getClientByClientId("test-app"), userSession, "http://redirect", "state"); userSessionsInner.add(userSession.getId()); + persistUserSession(session, userSession, true); } - - for (String userSessionId : userSessionsInner) { - UserSessionModel userSession2 = session.sessions().getUserSession(realm, userSessionId); - persistUserSession(session, userSession2, true); - } - return null; });