diff --git a/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc
index efe2c33db14..7e1c1559871 100644
--- a/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc
+++ b/docs/documentation/upgrading/topics/changes/changes-26_6_0.adoc
@@ -40,11 +40,11 @@ See the link:{upgradingguide_link}[{upgradingguide_name}] for details on how to
OIDC specification has multiple client authentication methods. Two of them `client_secret_basic` and `client_secret_post` are implemented in {project_name} by **Client Id and Secret**
client authenticator. Until now, when the OIDC client registration request was sent with the attribute `token_endpoint_auth_method` set to `client_secret_basic` or `client_secret_post`,
-the {project_name} allowed to authenticate client with both `client_secret_basic` and `client_secret_post` methods and did not preserved the single method specified in the OIDC client
+the {project_name} allowed to authenticate client with both `client_secret_basic` and `client_secret_post` methods and did not preserve the single method specified in the OIDC client
registration request. Now the specified method is respected, so for example when the OIDC client registration is sent with `token_endpoint_auth_method` set to `client_secret_post`, then
it is required to authenticate the client really with the client secret sent as a parameter in the POST request body.
-It is still possible to make the OIDC client to allow both methods and clients migrated from previous versions are set by default to allow authentication with both methods.
+It is still possible to make the OIDC client allow both methods and clients migrated from previous versions are set by default to allow authentication with both methods.
=== Usage of virtual threads for embedded caches
@@ -56,11 +56,11 @@ This change should prevent deadlocks due to pinned virtual threads.
Specific sensitive information is omitted from the HTTP Access log, such as the value of the `Authorization` HTTP header. Moreover, values of specific sensitive {project_name} cookies, such as `KEYCLOAK_SESSION`, `KEYCLOAK_IDENTITY`, or `AUTH_SESSION_ID`, are also omitted.
-=== Accepting URL paths without a semicolon
+=== Rejecting URL paths with semicolons
Previously {project_name} accepted HTTP requests with paths containing a semicolon (`;`).
-When processing them, it handled them as of RFC 3986 as a separator for matrix parameters, which basically ignored those parts.
-As this has led to a hard-to-configure URL filtering, for example, in reverse proxies, this is now disabled, and {project_name} responds with an HTTP 400 response code.
+When processing them, it handled them according to RFC 3986 as a separator for matrix parameters, which basically ignored those parts.
+As this has led to hard-to-configure URL filtering, for example, in reverse proxies, this is now disabled, and {project_name} responds with an HTTP 400 response code.
To analyze rejected requests in the server log, enable debug logging for `org.keycloak.quarkus.runtime.services.RejectNonNormalizedPathFilter`.
@@ -70,7 +70,7 @@ With this configuration, enable and review the HTTP access log to identify probl
=== Adjusted default clock-skew for not-before JWT token checks
Previous versions of {project_name} tested the issued-at timestamp with a clock-skew of zero seconds by default if this value was provided in a token.
-Be default, access tokens issued by {project_name} used for the admin API or for the user info endpoint by default do not contain a not-before claim in the token, but customized setups might have it.
+By default, access tokens issued by {project_name} used for the admin API or for the user info endpoint do not contain a not-before claim in the token, but customized setups might have it.
In a setup with such a claim configured and with clocks that were not fully synchronized, this could lead to rejecting tokens on a node that lagged behind, as it would consider the tokens not valid yet.
Starting with this version, {project_name} will honor a clock skew of 10 seconds for the issued-at timestamp by default which is aligned with the best practices for example of the https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1-6[FAPI 2.0 Security Profile].
@@ -84,11 +84,21 @@ All the `base` themes are now tagged as **abstract**, and they are not listed in
=== New database indexes on the `OFFLINE_CLIENT_SESSION` table
-The `OFFLINE_CLIENT_SESSION` table now contains two additional index `IDX_OFFLINE_CSS_BY_CLIENT_AND_REALM` and `IDX_OFFLINE_CSS_BY_USER_SESSION_AND_OFFLINE` to improve performance.
+The `OFFLINE_CLIENT_SESSION` table now contains two additional indexes `IDX_OFFLINE_CSS_BY_CLIENT_AND_REALM` and `IDX_OFFLINE_CSS_BY_USER_SESSION_AND_OFFLINE` to improve performance.
If the table contains more than 300000 entries, {project_name} will skip the index creation by default during the automatic schema migration and instead log the SQL statement on the console during migration to be applied manually after {project_name}'s startup.
See the link:{upgradingguide_link}[{upgradingguide_name}] for details on how to configure a different limit.
+=== `UserSessionProvider` API changes
+
+The internal `UserSessionProvider` interface received new methods for efficient streaming of user sessions, in both speed and memory.
+The streaming instances are read-only, meaning they will not change.
+The previous methods have been deprecated.
+Custom providers should implement these new methods to avoid performance penalties.
+They should avoid keeping the returned items in memory by for example binding them to the current transaction, in case the invoker needs to analyze all data.
+
+A similar change was done for the `UserSessionPersisterProvider`.
+
// ------------------------ Deprecated features ------------------------ //
== Deprecated features
@@ -97,7 +107,7 @@ The following sections provide details on deprecated features.
=== Deprecation of specific tracing properties in Keycloak CR
The `tracing.serviceName`, and `tracing.resourceAttributes` fields of the Keycloak CR, are now deprecated.
-You should use the new `telemetry.serviceName`, and `telemetry.resourceAttributes` fields citizens that are shared among all OpenTelemetry components - logs, metrics, and traces.
+You should use the new `telemetry.serviceName`, and `telemetry.resourceAttributes` fields that are shared among all OpenTelemetry components - logs, metrics, and traces.
// ------------------------ Removed features ------------------------ //
== Removed features
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableClientSession.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableClientSession.java
new file mode 100644
index 00000000000..b9e69327331
--- /dev/null
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableClientSession.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2026 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.sessions.infinispan;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.keycloak.models.AuthenticatedClientSessionModel;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserSessionModel;
+
+import static org.keycloak.models.sessions.infinispan.ImmutableSession.readOnly;
+
+/**
+ * An immutable {@link AuthenticatedClientSessionModel} implementation.
+ *
+ * All setters throw a {@link UnsupportedOperationException}.
+ */
+record ImmutableClientSession(
+ String id,
+ ClientModel client,
+ ImmutableUserSessionModel userSessionModel,
+ Map notes,
+ String redirectUri,
+ String action,
+ String protocol,
+ int timestamp,
+ int started
+) implements AuthenticatedClientSessionModel {
+
+
+ @Override
+ public String getId() {
+ return id;
+ }
+
+ @Override
+ public int getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public void setTimestamp(int timestamp) {
+ readOnly();
+ }
+
+ @Override
+ public int getStarted() {
+ return started;
+ }
+
+ @Override
+ public void detachFromUserSession() {
+ readOnly();
+ }
+
+ @Override
+ public UserSessionModel getUserSession() {
+ return userSessionModel;
+ }
+
+ @Override
+ public String getNote(String name) {
+ return notes.get(name);
+ }
+
+ @Override
+ public void setNote(String name, String value) {
+ readOnly();
+ }
+
+ @Override
+ public void removeNote(String name) {
+ readOnly();
+ }
+
+ @Override
+ public Map getNotes() {
+ return Collections.unmodifiableMap(notes);
+ }
+
+ @Override
+ public String getRedirectUri() {
+ return redirectUri;
+ }
+
+ @Override
+ public void setRedirectUri(String uri) {
+ readOnly();
+ }
+
+ @Override
+ public RealmModel getRealm() {
+ return userSessionModel().getRealm();
+ }
+
+ @Override
+ public ClientModel getClient() {
+ return client;
+ }
+
+ @Override
+ public String getAction() {
+ return action;
+ }
+
+ @Override
+ public void setAction(String action) {
+ readOnly();
+ }
+
+ @Override
+ public String getProtocol() {
+ return protocol;
+ }
+
+ @Override
+ public void setProtocol(String method) {
+ readOnly();
+ }
+}
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableSession.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableSession.java
new file mode 100644
index 00000000000..295605e2273
--- /dev/null
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableSession.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2026 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.sessions.infinispan;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper;
+import org.keycloak.models.sessions.infinispan.entities.AuthenticatedClientSessionEntity;
+import org.keycloak.models.sessions.infinispan.entities.ClientSessionKey;
+import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey;
+import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity;
+import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity;
+import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity;
+import org.keycloak.models.sessions.infinispan.query.ClientSessionQueries;
+import org.keycloak.models.sessions.infinispan.query.QueryHelper;
+import org.keycloak.models.sessions.infinispan.util.SessionExpirationPredicates;
+import org.keycloak.utils.StreamsUtil;
+
+import org.infinispan.Cache;
+import org.infinispan.client.hotrod.RemoteCache;
+
+/**
+ * Helper class to map a list of user and client sessions, from Infinispan caches, into an immutable session.
+ *
+ * It copies the data to a new instance to prevent observing changes made by other threads to the underlying cached
+ * instances.
+ */
+public final class ImmutableSession {
+
+ public static void readOnly() {
+ throw new UnsupportedOperationException("this instance is read-only");
+ }
+
+ public static Stream copyOf(KeycloakSession session,
+ Collection entityList,
+ SessionExpirationPredicates expiration,
+ Cache> cache) {
+ var clientSessionKeys = new HashSet();
+ var userSessionMap = new LinkedHashMap();
+ var users = session.users();
+ entityList.forEach(entity -> {
+ if (entity == null) {
+ return;
+ }
+ if (!Objects.equals(entity.getRealmId(), expiration.realm().getId())) {
+ return;
+ }
+ if (expiration.isUserSessionExpired(entity)) {
+ return;
+ }
+ var user = users.getUserById(expiration.realm(), entity.getUser());
+ if (user == null) {
+ return;
+ }
+ var copy = new ImmutableUserSessionModel(
+ entity.getId(),
+ expiration.realm(),
+ user,
+ entity.getBrokerSessionId(),
+ entity.getBrokerUserId(),
+ entity.getLoginUsername(),
+ entity.getIpAddress(),
+ entity.getAuthMethod(),
+ new HashMap<>(), // to break cyclic dependency between user and client session
+ Map.copyOf(entity.getNotes()),
+ entity.getState(),
+ entity.getStarted(),
+ entity.getLastSessionRefresh(),
+ entity.isRememberMe(),
+ expiration.offline()
+ );
+ entity.getClientSessions().forEach(clientId -> clientSessionKeys.add(new EmbeddedClientSessionKey(copy.id(), clientId)));
+ userSessionMap.put(copy.id(), copy);
+ });
+
+ populateClientSessions(userSessionMap, clientSessionKeys, expiration, cache);
+ return userSessionMap.values().stream()
+ .map(UserSessionModel.class::cast);
+ }
+
+ public static Stream copyOf(KeycloakSession session,
+ Collection entityList,
+ SessionExpirationPredicates expiration,
+ RemoteCache cache,
+ int batchSize) {
+ var userSessionMap = new LinkedHashMap();
+ var users = session.users();
+ entityList.forEach(entity -> {
+ if (entity == null) {
+ return;
+ }
+ if (!Objects.equals(entity.getRealmId(), expiration.realm().getId())) {
+ return;
+ }
+ if (expiration.isUserSessionExpired(entity)) {
+ return;
+ }
+ var user = users.getUserById(expiration.realm(), entity.getUserId());
+ if (user == null) {
+ return;
+ }
+ var copy = new ImmutableUserSessionModel(
+ entity.getUserSessionId(),
+ expiration.realm(),
+ user,
+ entity.getBrokerSessionId(),
+ entity.getBrokerUserId(),
+ entity.getLoginUsername(),
+ entity.getIpAddress(),
+ entity.getAuthMethod(),
+ new HashMap<>(), // to break cyclic dependency between user and client session
+ Map.copyOf(entity.getNotes()),
+ entity.getState(),
+ entity.getStarted(),
+ entity.getLastSessionRefresh(),
+ entity.isRememberMe(),
+ expiration.offline()
+ );
+ userSessionMap.put(copy.id(), copy);
+ });
+
+
+ populateClientSessions(userSessionMap, expiration, cache, batchSize);
+ return userSessionMap.values().stream().map(UserSessionModel.class::cast);
+ }
+
+ private static void populateClientSessions(Map userSessionMap, Set clientSessionKeys, SessionExpirationPredicates expirationPredicates, Cache> cache) {
+ StreamsUtil.closing(cache.entrySet().stream()
+ .filterKeys(clientSessionKeys))
+ .iterator()
+ .forEachRemaining(entry -> {
+ var clientSession = entry.getValue().getEntity();
+ var userSession = userSessionMap.get(entry.getKey().userSessionId());
+ var client = expirationPredicates.realm().getClientById(entry.getKey().clientId());
+ if (client == null || userSession == null) {
+ return;
+ }
+ if (expirationPredicates.isClientSessionExpired(clientSession, userSession.rememberMe(), client)) {
+ return;
+ }
+ var copy = new ImmutableClientSession(
+ entry.getKey().toId(),
+ client,
+ userSession,
+ Map.copyOf(clientSession.getNotes()),
+ clientSession.getRedirectUri(),
+ clientSession.getAction(),
+ clientSession.getAuthMethod(),
+ clientSession.getTimestamp(),
+ clientSession.getStarted()
+ );
+ userSession.clientSessions().put(entry.getKey().clientId(), copy);
+ });
+ }
+
+ private static void populateClientSessions(Map userSessionMap, SessionExpirationPredicates expirationPredicates, RemoteCache cache, int batchSize) {
+ var query = ClientSessionQueries.fetchClientSessions(cache, userSessionMap.keySet());
+ QueryHelper.streamAll(query, batchSize, Function.identity()).forEach(entity -> {
+ var userSession = userSessionMap.get(entity.getUserSessionId());
+ var client = expirationPredicates.realm().getClientById(entity.getClientId());
+ if (client == null || userSession == null) {
+ return;
+ }
+ if (expirationPredicates.isClientSessionExpired(entity, userSession.started(), userSession.rememberMe(), client)) {
+ return;
+ }
+ var copy = new ImmutableClientSession(
+ entity.createId(),
+ client,
+ userSession,
+ Map.copyOf(entity.getNotes()),
+ entity.getRedirectUri(),
+ entity.getAction(),
+ entity.getProtocol(),
+ entity.getTimestamp(),
+ entity.getStarted()
+ );
+ userSession.clientSessions().put(entity.getClientId(), copy);
+ });
+ }
+
+}
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableUserSessionModel.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableUserSessionModel.java
new file mode 100644
index 00000000000..d177f2544b8
--- /dev/null
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ImmutableUserSessionModel.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2026 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.keycloak.models.sessions.infinispan;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import org.keycloak.models.AuthenticatedClientSessionModel;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserSessionModel;
+
+import static org.keycloak.models.sessions.infinispan.ImmutableSession.readOnly;
+
+/**
+ * An immutable {@link UserSessionModel} implementation.
+ *
+ * All setters throw a {@link UnsupportedOperationException}.
+ */
+record ImmutableUserSessionModel(
+ String id,
+ RealmModel realm,
+ UserModel user,
+ String brokerSessionId,
+ String brokerUserId,
+ String loginUserName,
+ String ipAddress,
+ String authMethod,
+ Map clientSessions,
+ Map notes,
+ State state,
+ int started,
+ int lastSessionRefresh,
+ boolean rememberMe,
+ boolean offline
+) implements UserSessionModel {
+
+ @Override
+ public String getId() {
+ return id;
+ }
+
+ @Override
+ public RealmModel getRealm() {
+ return realm;
+ }
+
+ @Override
+ public String getBrokerSessionId() {
+ return brokerSessionId;
+ }
+
+ @Override
+ public String getBrokerUserId() {
+ return brokerUserId;
+ }
+
+ @Override
+ public UserModel getUser() {
+ return user;
+ }
+
+ @Override
+ public String getLoginUsername() {
+ return loginUserName;
+ }
+
+ @Override
+ public String getIpAddress() {
+ return ipAddress;
+ }
+
+ @Override
+ public String getAuthMethod() {
+ return authMethod;
+ }
+
+ @Override
+ public boolean isRememberMe() {
+ return rememberMe;
+ }
+
+ @Override
+ public int getStarted() {
+ return started;
+ }
+
+ @Override
+ public int getLastSessionRefresh() {
+ return lastSessionRefresh;
+ }
+
+ @Override
+ public void setLastSessionRefresh(int seconds) {
+ readOnly();
+ }
+
+ @Override
+ public boolean isOffline() {
+ return offline;
+ }
+
+ @Override
+ public Map getAuthenticatedClientSessions() {
+ return Collections.unmodifiableMap(clientSessions);
+ }
+
+ @Override
+ public void removeAuthenticatedClientSessions(Collection removedClientUUIDS) {
+ readOnly();
+ }
+
+ @Override
+ public String getNote(String name) {
+ return notes.get(name);
+ }
+
+ @Override
+ public void setNote(String name, String value) {
+ readOnly();
+ }
+
+ @Override
+ public void removeNote(String name) {
+ readOnly();
+ }
+
+ @Override
+ public Map getNotes() {
+ return Collections.unmodifiableMap(notes);
+ }
+
+ @Override
+ public State getState() {
+ return state;
+ }
+
+ @Override
+ public void setState(State state) {
+ readOnly();
+ }
+
+ @Override
+ public void restartSession(RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) {
+ readOnly();
+ }
+}
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
index 3333d019064..71d87196093 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
@@ -70,9 +70,11 @@ import org.keycloak.models.sessions.infinispan.stream.Mappers;
import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate;
import org.keycloak.models.sessions.infinispan.stream.UserSessionPredicate;
import org.keycloak.models.sessions.infinispan.util.FuturesHelper;
+import org.keycloak.models.sessions.infinispan.util.SessionExpirationPredicates;
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;
import org.keycloak.utils.StreamsUtil;
+import io.reactivex.rxjava3.core.Flowable;
import org.infinispan.Cache;
import org.infinispan.commons.api.AsyncCache;
import org.infinispan.commons.util.concurrent.CompletionStages;
@@ -466,6 +468,48 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi
.sorted(Comparator.comparing(UserSessionModel::getLastSessionRefresh))), firstResult, maxResults);
}
+ @Override
+ public Stream readOnlyStreamOfflineUserSessions(RealmModel realm) {
+ var expiration = new SessionExpirationPredicates(realm, true, Time.currentTime());
+ return session.getProvider(UserSessionPersisterProvider.class).readOnlyUserSessionStream(realm, true)
+ .filter(Predicate.not(expiration::isUserSessionExpired));
+ }
+
+ @Override
+ public Stream readOnlyStreamUserSessions(RealmModel realm) {
+ return readOnlyStreamFromCache(UserSessionPredicate.create(realm.getId()), realm);
+ }
+
+ @Override
+ public Stream readOnlyStreamOfflineUserSessions(RealmModel realm, ClientModel client) {
+ var expiration = new SessionExpirationPredicates(realm, true, Time.currentTime());
+ return session.getProvider(UserSessionPersisterProvider.class).readOnlyUserSessionStream(realm, client, true)
+ .filter(Predicate.not(expiration::isUserSessionExpired));
+ }
+
+ @Override
+ public Stream readOnlyStreamUserSessions(RealmModel realm, ClientModel client) {
+ return readOnlyStreamFromCache(UserSessionPredicate.create(realm.getId()).client(client.getId()), realm);
+ }
+
+ private Stream readOnlyStreamFromCache(UserSessionPredicate cachePredicate, RealmModel realm) {
+ var predicates = new SessionExpirationPredicates(realm, false, Time.currentTime());
+ var clientSessionCache = getClientSessionCache(false);
+
+ // not great, distributed sort not supported, and we're sorting everything locally
+ // follow-up, iterate by segment and sort the sessions in that segment.
+ var stream = StreamsUtil.closing(getCache(false).entrySet().stream()
+ .filter(cachePredicate)
+ .map(Mappers.userSessionEntity())
+ .sorted(Comparator.comparing(UserSessionEntity::getId)));
+
+ return Flowable.fromIterable(stream::iterator)
+ .buffer(512)
+ .map(us -> ImmutableSession.copyOf(session, us, predicates, clientSessionCache))
+ .blockingStream()
+ .flatMap(Function.identity());
+ }
+
@Override
public UserSessionModel getUserSessionWithPredicate(RealmModel realm, String id, boolean offline, Predicate predicate) {
UserSessionModel userSession = getUserSession(realm, id, offline);
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java
index 0407a0ac121..dcd4470f382 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java
@@ -75,6 +75,7 @@ import org.keycloak.models.sessions.infinispan.stream.RemoveKeyConsumer;
import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate;
import org.keycloak.models.sessions.infinispan.stream.UserSessionPredicate;
import org.keycloak.models.sessions.infinispan.util.FuturesHelper;
+import org.keycloak.models.sessions.infinispan.util.SessionExpirationPredicates;
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.UserModelDelegate;
@@ -414,6 +415,38 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi
session.getProvider(UserSessionPersisterProvider.class).removeUserSessions(realm);
}
+ @Override
+ public Stream readOnlyStreamUserSessions(RealmModel realm) {
+ return readOnlyStream(realm, false);
+ }
+
+ @Override
+ public Stream readOnlyStreamOfflineUserSessions(RealmModel realm) {
+ return readOnlyStream(realm, true);
+ }
+
+ @Override
+ public Stream readOnlyStreamOfflineUserSessions(RealmModel realm, ClientModel client) {
+ return readOnlyStream(realm, client, true);
+ }
+
+ @Override
+ public Stream readOnlyStreamUserSessions(RealmModel realm, ClientModel client) {
+ return readOnlyStream(realm, client, false);
+ }
+
+ private Stream readOnlyStream(RealmModel realm, boolean offline) {
+ var expiration = new SessionExpirationPredicates(realm, offline, Time.currentTime());
+ return session.getProvider(UserSessionPersisterProvider.class).readOnlyUserSessionStream(realm, offline)
+ .filter(Predicate.not(expiration::isUserSessionExpired));
+ }
+
+ private Stream readOnlyStream(RealmModel realm, ClientModel client, boolean offline) {
+ var expiration = new SessionExpirationPredicates(realm, offline, Time.currentTime());
+ return session.getProvider(UserSessionPersisterProvider.class).readOnlyUserSessionStream(realm, client, offline)
+ .filter(Predicate.not(expiration::isUserSessionExpired));
+ }
+
protected void onRemoveUserSessionsEvent(String realmId) {
removeLocalUserSessions(realmId, false);
removeLocalUserSessions(realmId, true);
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java
index b799a51b362..83a71fd3c56 100644
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/ClientSessionQueries.java
@@ -17,6 +17,8 @@
package org.keycloak.models.sessions.infinispan.query;
+import java.util.Collection;
+
import org.keycloak.marshalling.Marshalling;
import org.keycloak.models.sessions.infinispan.entities.ClientSessionKey;
import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity;
@@ -38,6 +40,7 @@ public final class ClientSessionQueries {
private static final String PER_CLIENT_COUNT = "SELECT e.clientId, count(e.clientId) FROM %s as e WHERE e.realmId = :realmId GROUP BY e.clientId ORDER BY e.clientId".formatted(CLIENT_SESSION);
private static final String CLIENT_SESSION_COUNT = "SELECT count(e) FROM %s as e WHERE e.realmId = :realmId && e.clientId = :clientId".formatted(CLIENT_SESSION);
private static final String FROM_USER_SESSION = "FROM %s as e WHERE e.userSessionId = :userSessionId ORDER BY e.clientId".formatted(CLIENT_SESSION);
+ private static final String FROM_MULTI_USER_SESSION = "FROM %s as e WHERE e.userSessionId IN (:userSessionIds) ORDER BY e.clientId".formatted(CLIENT_SESSION);
private static final String IDS_FROM_USER_SESSION = "SELECT e.clientId FROM %s as e WHERE e.userSessionId = :userSessionId ORDER BY e.clientId".formatted(CLIENT_SESSION);
/**
@@ -58,7 +61,7 @@ public final class ClientSessionQueries {
}
/**
- * Returns a projection with the sum of all client session belonging to the client ID.
+ * Returns a projection with the sum of all client sessions belonging to the client ID.
*/
public static Query