From e40c5de050a44a29dee3d9dc0e95ef543b874cd0 Mon Sep 17 00:00:00 2001 From: Pedro Ruivo Date: Thu, 30 Oct 2025 21:01:09 +0000 Subject: [PATCH] Session cache affinity Closes #42776 Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Signed-off-by: Alexander Schwartz Signed-off-by: Alexander Schwartz Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com> Co-authored-by: Alexander Schwartz Co-authored-by: Steven Hawkins Co-authored-by: Alexander Schwartz --- .../keycloak/common/util/SecretGenerator.java | 29 +++++++++++ .../topics/changes/changes-26_5_0.adoc | 7 +++ ...finispanAuthenticationSessionProvider.java | 16 +++--- ...nAuthenticationSessionProviderFactory.java | 7 ++- .../InfinispanUserSessionProvider.java | 7 +-- .../InfinispanUserSessionProviderFactory.java | 23 ++------- .../PersistentUserSessionProvider.java | 13 +++-- .../infinispan/changes/CacheHolder.java | 5 +- .../InfinispanChangelogBasedTransaction.java | 5 ++ .../changes/InfinispanChangesUtils.java | 32 +++++++++++- ...tentSessionsChangelogBasedTransaction.java | 5 ++ ...finispanAuthenticationSessionProvider.java | 4 +- .../remote/RemoteUserSessionProvider.java | 3 +- .../util/InfinispanKeyGenerator.java | 3 ++ .../impl/embedded/CacheConfigurator.java | 8 ++- .../embedded/ClientSessionKeyGrouper.java | 45 +++++++++++++++++ .../testframework/events/EventMatchers.java | 28 +++++++++++ .../keycloak/tests/admin/ConsentsTest.java | 5 +- .../tests/admin/ImpersonationTest.java | 2 +- .../org/keycloak/testsuite/AssertEvents.java | 50 +++++++++++++++---- .../AppInitiatedActionTotpSetupTest.java | 2 +- .../actions/RequiredActionTotpSetupTest.java | 2 +- .../keycloak/testsuite/client/CIBATest.java | 2 +- .../keycloak/testsuite/forms/LoginTest.java | 4 +- .../oauth/AuthorizationCodeTest.java | 2 +- .../testsuite/oauth/TokenRevocationTest.java | 4 +- .../keycloak/testsuite/oauth/hok/HoKTest.java | 2 +- .../StandardTokenExchangeV2Test.java | 28 +++++------ .../UserSessionPersisterProviderTest.java | 2 +- 29 files changed, 256 insertions(+), 89 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/ClientSessionKeyGrouper.java diff --git a/common/src/main/java/org/keycloak/common/util/SecretGenerator.java b/common/src/main/java/org/keycloak/common/util/SecretGenerator.java index ef6f543c5c8..557d71d3d0d 100644 --- a/common/src/main/java/org/keycloak/common/util/SecretGenerator.java +++ b/common/src/main/java/org/keycloak/common/util/SecretGenerator.java @@ -1,13 +1,27 @@ package org.keycloak.common.util; import java.security.SecureRandom; +import java.util.Base64; import java.util.UUID; +import java.util.function.Supplier; public class SecretGenerator { public static final int SECRET_LENGTH_256_BITS = 32; public static final int SECRET_LENGTH_384_BITS = 48; public static final int SECRET_LENGTH_512_BITS = 64; + /** + * Session ID length in bytes. + *

+ * Both NIST and ANSSI ask for at least 128 bits of entropy, see #38663. + * As we are about to filter those session IDs on each node to find a key of the local segment using Infinispan's org.infinispan.affinity.KeyAffinityServiceFactory, + * we add some more entropy so that the filtering then leaves enough entropy for those IDs. + * Usually there are 256 segments in a cache. Just in case someone increases it, we add 16 bits. + * This should handle the case when a caller connects to one node and generates codes (as it is the case with a keep-alive HTTP connection), + * instead of a caller connecting to a random node on each request. + */ + private static final int SESSION_ID_BYTES = 18; + public static final Supplier SECURE_ID_GENERATOR = () -> getInstance().generateBase64SecureId(SESSION_ID_BYTES); public static final char[] UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); @@ -30,6 +44,21 @@ public class SecretGenerator { return generateSecureUUID().toString(); } + public String generateBase64SecureId(int nBytes) { + assert nBytes > 0; + byte[] data = new byte[nBytes]; + SECURE_RANDOM.nextBytes(data); + String id = Base64.getUrlEncoder().encodeToString(data); + + // disallow a dot, as a dot is used as a separator in AuthenticationSessionManager.decodeBase64AndValidateSignature + assert !id.contains("."); + + // disallow a space, as session_state must not contain a space (see https://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions) + assert !id.contains(" "); + + return id; + } + public String randomString() { return randomString(SECRET_LENGTH_256_BITS, ALPHANUM); } diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index 29d82496c6f..b01de2d7837 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -44,6 +44,13 @@ To revert to the previoius behavior and to accept non-normalized URLs, set the o Notable changes may include internal behavior changes that prevent common misconfigurations, bugs that are fixed, or changes to simplify running {project_name}. +=== `session_state` and `sid` are no longer UUIDs + +In OpenID connect, there are several places where the protocol shares a `session_state` and a `sid`. +The specifications define it as an opaque string. +Previous versions of {project_name} used a UUID for it, while the current version now uses a random base64-encoded string. +The length of the string was reduced from 36 characters to 24 characters, although it might increase in the future if additional randomness is required. + === `log-console-color` will automatically enable if supported by the terminal The `log-console-color` previously defaulted to `false`, but it will now instead check if the terminal supports color. diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java index 813bc3aaf08..3e9d58b2287 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java @@ -17,6 +17,9 @@ package org.keycloak.models.sessions.infinispan; +import java.util.Iterator; +import java.util.Map; + import org.infinispan.Cache; import org.infinispan.commons.util.concurrent.CompletionStages; import org.infinispan.factories.ComponentRegistry; @@ -37,13 +40,11 @@ import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessio import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.events.SessionEventsSenderTransaction; import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate; -import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.RootAuthenticationSessionModel; -import java.util.Iterator; -import java.util.Map; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME; /** * @author Marek Posolda @@ -51,15 +52,13 @@ import java.util.Map; public class InfinispanAuthenticationSessionProvider implements AuthenticationSessionProvider { private final KeycloakSession session; - private final InfinispanKeyGenerator keyGenerator; private final int authSessionsLimit; protected final InfinispanChangelogBasedTransaction sessionTx; protected final SessionEventsSenderTransaction clusterEventsSenderTx; - public InfinispanAuthenticationSessionProvider(KeycloakSession session, InfinispanKeyGenerator keyGenerator, + public InfinispanAuthenticationSessionProvider(KeycloakSession session, InfinispanChangelogBasedTransaction sessionTx, int authSessionsLimit) { this.session = session; - this.keyGenerator = keyGenerator; this.authSessionsLimit = authSessionsLimit; this.sessionTx = sessionTx; this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); @@ -69,8 +68,7 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe @Override public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm) { - String id = keyGenerator.generateKeyString(session, sessionTx.getCache()); - return createRootAuthenticationSession(realm, id); + return createRootAuthenticationSession(realm, sessionTx.generateKey()); } @@ -185,7 +183,7 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe public void migrate(String modelVersion) { if ("26.1.0".equals(modelVersion)) { InfinispanConnectionProvider infinispanConnectionProvider = session.getProvider(InfinispanConnectionProvider.class); - Cache authSessionsCache = infinispanConnectionProvider.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME); + Cache authSessionsCache = infinispanConnectionProvider.getCache(AUTHENTICATION_SESSIONS_CACHE_NAME); CompletionStages.join(ComponentRegistry.componentOf(authSessionsCache, PersistenceManager.class).clearAllStores(PersistenceManager.AccessMode.BOTH)); } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java index 0178b187c87..f5038ee2d60 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java @@ -27,6 +27,7 @@ import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.cluster.ClusterEvent; import org.keycloak.cluster.ClusterProvider; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.infinispan.util.InfinispanUtils; import org.keycloak.models.KeycloakSession; @@ -41,7 +42,6 @@ import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessio import org.keycloak.models.sessions.infinispan.events.AbstractAuthSessionClusterListener; import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProvider; -import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.PostMigrationEvent; @@ -62,7 +62,6 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic private static final Logger log = Logger.getLogger(InfinispanAuthenticationSessionProviderFactory.class); - private final InfinispanKeyGenerator keyGenerator = new InfinispanKeyGenerator(); private CacheHolder cacheHolder; private int authSessionsLimit; @@ -90,7 +89,7 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic public void postInit(KeycloakSessionFactory factory) { factory.register(this); try (var session = factory.create()) { - cacheHolder = InfinispanChangesUtils.createWithCache(session, AUTHENTICATION_SESSIONS_CACHE_NAME, SessionTimeouts::getAuthSessionLifespanMS, SessionTimeouts::getAuthSessionMaxIdleMS); + cacheHolder = InfinispanChangesUtils.createWithCache(session, AUTHENTICATION_SESSIONS_CACHE_NAME, SessionTimeouts::getAuthSessionLifespanMS, SessionTimeouts::getAuthSessionMaxIdleMS, SecretGenerator.SECURE_ID_GENERATOR); } } @@ -132,7 +131,7 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic @Override public InfinispanAuthenticationSessionProvider create(KeycloakSession session) { - return new InfinispanAuthenticationSessionProvider(session, keyGenerator, createTransaction(session), authSessionsLimit); + return new InfinispanAuthenticationSessionProvider(session, createTransaction(session), authSessionsLimit); } @Override 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 f79428e46f2..1b59ad6b10b 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 @@ -75,7 +75,6 @@ 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.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.utils.StreamsUtil; @@ -103,15 +102,12 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi protected final PersisterLastSessionRefreshStore persisterLastSessionRefreshStore; - protected final InfinispanKeyGenerator keyGenerator; - protected final SessionFunction offlineSessionCacheEntryLifespanAdjuster; protected final SessionFunction offlineClientSessionCacheEntryLifespanAdjuster; public InfinispanUserSessionProvider(KeycloakSession session, PersisterLastSessionRefreshStore persisterLastSessionRefreshStore, - InfinispanKeyGenerator keyGenerator, InfinispanChangelogBasedTransaction sessionTx, InfinispanChangelogBasedTransaction offlineSessionTx, InfinispanChangelogBasedTransaction clientSessionTx, @@ -128,7 +124,6 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); this.persisterLastSessionRefreshStore = persisterLastSessionRefreshStore; - this.keyGenerator = keyGenerator; this.offlineSessionCacheEntryLifespanAdjuster = offlineSessionCacheEntryLifespanAdjuster; this.offlineClientSessionCacheEntryLifespanAdjuster = offlineClientSessionCacheEntryLifespanAdjuster; @@ -182,7 +177,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider, Sessi public UserSessionModel createUserSession(String id, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId, UserSessionModel.SessionPersistenceState persistenceState) { if (id == null) { - id = keyGenerator.generateKeyString(session, sessionTx.getCache()); + id = sessionTx.generateKey(); } UserSessionEntity entity = UserSessionEntity.create(id, realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); 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 db696d4d55b..9810257300a 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 @@ -24,12 +24,11 @@ import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; -import org.infinispan.Cache; -import org.infinispan.affinity.KeyGenerator; import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.cluster.ClusterProvider; import org.keycloak.common.util.MultiSiteUtils; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.infinispan.util.InfinispanUtils; import org.keycloak.models.ClientModel; @@ -55,7 +54,6 @@ import org.keycloak.models.sessions.infinispan.events.AbstractUserSessionCluster import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent; import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent; import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProvider; -import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.PostMigrationEvent; @@ -97,7 +95,6 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider private long offlineClientSessionCacheEntryLifespanOverride; private PersisterLastSessionRefreshStore persisterLastSessionRefreshStore; - private InfinispanKeyGenerator keyGenerator; ArrayBlockingQueue asyncQueuePersistentUpdate; private PersistentSessionsWorker persistentSessionsWorker; private int maxBatchSize; @@ -110,7 +107,6 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider var tx = createPersistentTransaction(session); return new PersistentUserSessionProvider( session, - keyGenerator, tx.userTx, tx.clientTx ); @@ -119,7 +115,6 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider return new InfinispanUserSessionProvider( session, persisterLastSessionRefreshStore, - keyGenerator, tx.sessionTx, tx.offlineSessionTx, tx.clientSessionTx, @@ -154,17 +149,9 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider public void postInit(final KeycloakSessionFactory factory) { factory.register(event -> { if (event instanceof PostMigrationEvent) { - if (!useCaches) { - keyGenerator = new InfinispanKeyGenerator() { - @Override - protected K generateKey(KeycloakSession session, Cache cache, KeyGenerator keyGenerator) { - return keyGenerator.getKey(); - } - }; - } else { + if (useCaches) { KeycloakModelUtils.runJobInTransaction(factory, (KeycloakSession session) -> { - keyGenerator = new InfinispanKeyGenerator(); if (!MultiSiteUtils.isPersistentSessionsEnabled()) { initializePersisterLastSessionRefreshStore(factory); } @@ -199,20 +186,20 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider if (MultiSiteUtils.isPersistentSessionsEnabled()) { if (useCaches) { try (var session = factory.create()) { - sessionCacheHolder = InfinispanChangesUtils.createWithCache(session, USER_SESSION_CACHE_NAME, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs); + sessionCacheHolder = InfinispanChangesUtils.createWithCache(session, USER_SESSION_CACHE_NAME, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs, SecretGenerator.SECURE_ID_GENERATOR); offlineSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_USER_SESSION_CACHE_NAME, SessionTimeouts::getOfflineSessionLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs); clientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, CLIENT_SESSION_CACHE_NAME, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs); offlineClientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_CLIENT_SESSION_CACHE_NAME, SessionTimeouts::getOfflineClientSessionLifespanMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs); } } else { - sessionCacheHolder = InfinispanChangesUtils.createWithoutCache(SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs); + sessionCacheHolder = InfinispanChangesUtils.createWithoutCache(SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs, SecretGenerator.SECURE_ID_GENERATOR); offlineSessionCacheHolder = InfinispanChangesUtils.createWithoutCache(SessionTimeouts::getOfflineSessionLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs); clientSessionCacheHolder = InfinispanChangesUtils.createWithoutCache(SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs); offlineClientSessionCacheHolder = InfinispanChangesUtils.createWithoutCache(SessionTimeouts::getOfflineClientSessionLifespanMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs); } } else { try (var session = factory.create()) { - sessionCacheHolder = InfinispanChangesUtils.createWithCache(session, USER_SESSION_CACHE_NAME, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs); + sessionCacheHolder = InfinispanChangesUtils.createWithCache(session, USER_SESSION_CACHE_NAME, SessionTimeouts::getUserSessionLifespanMs, SessionTimeouts::getUserSessionMaxIdleMs, SecretGenerator.SECURE_ID_GENERATOR); 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); 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 abe1e944b9e..78f0541b713 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 @@ -82,11 +82,14 @@ 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.InfinispanKeyGenerator; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.UserModelDelegate; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME; +import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME; +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.Constants.SESSION_NOTE_LIGHTWEIGHT_USER; import static org.keycloak.models.sessions.infinispan.changes.ClientSessionPersistentChangelogBasedTransaction.createAuthenticatedClientSessionInstance; import static org.keycloak.utils.StreamsUtil.paginatedStream; @@ -105,10 +108,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi protected final SessionEventsSenderTransaction clusterEventsSenderTx; - protected final InfinispanKeyGenerator keyGenerator; - public PersistentUserSessionProvider(KeycloakSession session, - InfinispanKeyGenerator keyGenerator, UserSessionPersistentChangelogBasedTransaction sessionTx, ClientSessionPersistentChangelogBasedTransaction clientSessionTx) { if (!MultiSiteUtils.isPersistentSessionsEnabled()) { @@ -119,7 +119,6 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi this.sessionTx = sessionTx; this.clientSessionTx = clientSessionTx; this.clusterEventsSenderTx = new SessionEventsSenderTransaction(session); - this.keyGenerator = keyGenerator; session.getTransactionManager().enlistAfterCompletion(clusterEventsSenderTx); } @@ -183,7 +182,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi public UserSessionModel createUserSession(String id, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId, UserSessionModel.SessionPersistenceState persistenceState) { if (id == null) { - id = keyGenerator.generateKeyString(session, sessionTx.getCache(false)); + id = sessionTx.generateKey(); } UserSessionEntity entity = new UserSessionEntity(id); @@ -847,7 +846,7 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi // This is a best-effort approach: Even if due to a rolling update some entries are left there, the checking of sessions and tokens does not depend on them. // Refreshing of tokens will still work even if the user session does not contain the list of client sessions. var stage = CompletionStages.aggregateCompletionStage(); - Stream.of(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME, InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME, InfinispanConnectionProvider.OFFLINE_CLIENT_SESSION_CACHE_NAME) + Stream.of(USER_SESSION_CACHE_NAME, OFFLINE_USER_SESSION_CACHE_NAME, CLIENT_SESSION_CACHE_NAME, OFFLINE_CLIENT_SESSION_CACHE_NAME) .map(s -> { InfinispanConnectionProvider provider = session.getProvider(InfinispanConnectionProvider.class); if (provider != null) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/CacheHolder.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/CacheHolder.java index f9faf68df77..097d8d07fe0 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/CacheHolder.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/CacheHolder.java @@ -17,6 +17,8 @@ package org.keycloak.models.sessions.infinispan.changes; +import java.util.function.Supplier; + import org.infinispan.Cache; import org.infinispan.util.concurrent.ActionSequencer; import org.keycloak.models.sessions.infinispan.SessionFunction; @@ -29,5 +31,6 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; public record CacheHolder(Cache> cache, ActionSequencer sequencer, SessionFunction lifespanFunction, - SessionFunction maxIdleFunction) { + SessionFunction maxIdleFunction, + Supplier keyGenerator) { } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java index 184ba4a9263..a22d26f58c8 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangelogBasedTransaction.java @@ -187,6 +187,11 @@ public class InfinispanChangelogBasedTransaction imp return cacheHolder.cache(); } + public K generateKey() { + assert cacheHolder.keyGenerator() != null; + return cacheHolder.keyGenerator().get(); + } + /** * Imports a session from an external source into the {@link Cache}. *

diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangesUtils.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangesUtils.java index f82b7a00115..6a58b42c0ae 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangesUtils.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/InfinispanChangesUtils.java @@ -20,8 +20,10 @@ package org.keycloak.models.sessions.infinispan.changes; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import org.infinispan.Cache; +import org.infinispan.affinity.KeyAffinityServiceFactory; import org.infinispan.commons.util.concurrent.AggregateCompletionStage; import org.infinispan.commons.util.concurrent.CompletableFutures; import org.infinispan.commons.util.concurrent.CompletionStages; @@ -39,6 +41,9 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; */ public class InfinispanChangesUtils { + // by default, keep 128 keys ready to use + private static final int DEFAULT_KEY_BUFFER = 128; + private InfinispanChangesUtils() { } @@ -46,15 +51,38 @@ public class InfinispanChangesUtils { String cacheName, SessionFunction lifespanFunction, SessionFunction maxIdleFunction) { + return createWithCache(session, cacheName, lifespanFunction, maxIdleFunction, null); + } + + public static CacheHolder createWithCache(KeycloakSession session, + String cacheName, + SessionFunction lifespanFunction, + SessionFunction maxIdleFunction, + Supplier keyGenerator) { var connections = session.getProvider(InfinispanConnectionProvider.class); var cache = connections.>getCache(cacheName); var sequencer = new ActionSequencer(connections.getExecutor(cacheName + "Replace"), false, null); - return new CacheHolder<>(cache, sequencer, lifespanFunction, maxIdleFunction); + if (!cache.getCacheConfiguration().clustering().cacheMode().isClustered() || keyGenerator == null) { + return new CacheHolder<>(cache, sequencer, lifespanFunction, maxIdleFunction, keyGenerator); + } + var local = cache.getAdvancedCache().getRpcManager().getAddress(); + var affinity = KeyAffinityServiceFactory.newLocalKeyAffinityService( + cache, + keyGenerator::get, + connections.getExecutor(cacheName + "KeyGenerator"), + DEFAULT_KEY_BUFFER); + return new CacheHolder<>(cache, sequencer, lifespanFunction, maxIdleFunction, () -> affinity.getKeyForAddress(local)); } public static CacheHolder createWithoutCache(SessionFunction lifespanFunction, SessionFunction maxIdleFunction) { - return new CacheHolder<>(null, null, lifespanFunction, maxIdleFunction); + return new CacheHolder<>(null, null, lifespanFunction, maxIdleFunction, null); + } + + public static CacheHolder createWithoutCache(SessionFunction lifespanFunction, + SessionFunction maxIdleFunction, + Supplier keyGenerator) { + return new CacheHolder<>(null, null, lifespanFunction, maxIdleFunction, keyGenerator); } public static void runOperationInCluster( diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java index 1c32c3ed8bf..d5c57babee6 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/PersistentSessionsChangelogBasedTransaction.java @@ -77,6 +77,11 @@ abstract public class PersistentSessionsChangelogBasedTransaction get(K key, boolean offline) { SessionUpdatesList myUpdates = getUpdates(offline).get(key); if (myUpdates == null) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java index d7f817778db..edd32742a85 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteInfinispanAuthenticationSessionProvider.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Objects; import org.keycloak.cluster.ClusterProvider; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.common.util.Time; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -29,7 +30,6 @@ import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNote import org.keycloak.models.sessions.infinispan.InfinispanAuthenticationSessionProviderFactory; import org.keycloak.models.sessions.infinispan.entities.RootAuthenticationSessionEntity; import org.keycloak.models.sessions.infinispan.remote.transaction.AuthenticationSessionChangeLogTransaction; -import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.RootAuthenticationSessionModel; @@ -53,7 +53,7 @@ public class RemoteInfinispanAuthenticationSessionProvider implements Authentica @Override public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm) { - return createRootAuthenticationSession(realm, KeycloakModelUtils.generateId()); + return createRootAuthenticationSession(realm, SecretGenerator.SECURE_ID_GENERATOR.get()); } @Override 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 d566e8866ba..67bce8b4b4d 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 @@ -37,6 +37,7 @@ import org.infinispan.commons.util.concurrent.CompletionStages; import org.jboss.logging.Logger; import org.keycloak.cluster.ClusterProvider; import org.keycloak.common.Profile; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -111,7 +112,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider { @Override public UserSessionModel createUserSession(String id, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId, UserSessionModel.SessionPersistenceState persistenceState) { if (id == null) { - id = KeycloakModelUtils.generateId(); + id = SecretGenerator.SECURE_ID_GENERATOR.get(); } var entity = RemoteUserSessionEntity.create(id, realm, user, loginUsername, ipAddress, authMethod, rememberMe, brokerSessionId, brokerUserId); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanKeyGenerator.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanKeyGenerator.java index f7669dc5592..d892075bceb 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanKeyGenerator.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/InfinispanKeyGenerator.java @@ -29,11 +29,14 @@ import org.infinispan.affinity.KeyGenerator; import org.jboss.logging.Logger; import org.keycloak.common.util.SecretGenerator; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.sessions.infinispan.changes.CacheHolder; import org.keycloak.sessions.StickySessionEncoderProvider; /** * @author Marek Posolda + * @deprecated not supported and to be removed. Check {@link CacheHolder#keyGenerator()} */ +@Deprecated(since = "26.4", forRemoval = true) public class InfinispanKeyGenerator { private static final Logger log = Logger.getLogger(InfinispanKeyGenerator.class); diff --git a/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/CacheConfigurator.java b/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/CacheConfigurator.java index 0475cf14d67..38603ec2950 100644 --- a/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/CacheConfigurator.java +++ b/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/CacheConfigurator.java @@ -482,8 +482,14 @@ public final class CacheConfigurator { switch (cacheName) { // Distributed Caches case CLIENT_SESSION_CACHE_NAME: - case USER_SESSION_CACHE_NAME: case OFFLINE_CLIENT_SESSION_CACHE_NAME: + // Groups keys by user session ID. + if (clustered) { + builder.clustering().hash().groups() + .enabled() + .addGrouper(ClientSessionKeyGrouper.INSTANCE); + } + case USER_SESSION_CACHE_NAME: case OFFLINE_USER_SESSION_CACHE_NAME: if (clustered) { builder.clustering().cacheMode(CacheMode.DIST_SYNC).hash().numOwners(1); diff --git a/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/ClientSessionKeyGrouper.java b/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/ClientSessionKeyGrouper.java new file mode 100644 index 00000000000..f36e613bd26 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/spi/infinispan/impl/embedded/ClientSessionKeyGrouper.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.spi.infinispan.impl.embedded; + +import org.infinispan.distribution.group.Grouper; +import org.keycloak.models.sessions.infinispan.entities.EmbeddedClientSessionKey; + +/** + * A {@link Grouper} implementation that uses the User Session ID to assign the Client Session to the cache segment. It + * groups all the Client Sessions belonging to the same User Session in the same node where the User Session lives. + */ +public enum ClientSessionKeyGrouper implements Grouper { + + INSTANCE; + + // The Infinispan parser expects a constructor or a static "getInstance" method; fixes ClusterConfigKeepAliveDistTest. + public static ClientSessionKeyGrouper getInstance() { + return INSTANCE; + } + + @Override + public Object computeGroup(EmbeddedClientSessionKey key, Object group) { + return key.userSessionId(); + } + + @Override + public Class getKeyType() { + return EmbeddedClientSessionKey.class; + } +} diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventMatchers.java b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventMatchers.java index 85ac6448b65..5804c8dfc6e 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventMatchers.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventMatchers.java @@ -2,9 +2,11 @@ package org.keycloak.testframework.events; import org.hamcrest.Description; import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.hamcrest.TypeSafeMatcher; import java.util.UUID; +import java.util.regex.Pattern; public class EventMatchers { @@ -12,6 +14,32 @@ public class EventMatchers { return new UUIDMatcher(); } + public static Matcher isCodeId() { + // Make the tests pass with the old and the new encoding of code IDs + return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID()); + } + + public static Matcher isSessionId() { + // Make the tests pass with the old and the new encoding of sessions + return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID()); + } + + public static Matcher isBase64WithAtLeast128Bits() { + return new TypeSafeMatcher<>() { + private static final Pattern BASE64 = Pattern.compile("[-A-Za-z0-9+/_]*"); + + @Override + protected boolean matchesSafely(String item) { + return item.length() >= 24 && item.matches(BASE64.pattern()); + } + + @Override + public void describeTo(Description description) { + description.appendText("not an base64 ID with at least 128bits"); + } + }; + } + private EventMatchers() { } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/ConsentsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/ConsentsTest.java index d4948ba1681..42579078e7b 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/ConsentsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/ConsentsTest.java @@ -17,6 +17,7 @@ package org.keycloak.tests.admin; +import org.hamcrest.MatcherAssert; import org.jboss.logging.Logger; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -41,6 +42,7 @@ import org.keycloak.testframework.annotations.InjectEvents; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.InjectUser; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.EventMatchers; import org.keycloak.testframework.events.Events; import org.keycloak.testframework.injection.LifeCycle; import org.keycloak.testframework.oauth.OAuthClient; @@ -302,8 +304,7 @@ public class ConsentsTest { Assertions.assertEquals(EventType.LOGIN.toString(), loginEvent.getType()); loginEvent.getDetails().forEach((key, value) -> { switch (key) { - case Details.CODE_ID -> - Assertions.assertTrue(isUUID(value)); + case Details.CODE_ID -> MatcherAssert.assertThat(value, EventMatchers.isCodeId()); case Details.USERNAME -> Assertions.assertEquals(userFromUserRealm.getUsername(), value); case Details.CONSENT -> Assertions.assertEquals(Details.CONSENT_VALUE_NO_CONSENT_REQUIRED, value); case Details.REDIRECT_URI -> Assertions.assertEquals("http://127.0.0.1:8500/callback/oauth", value); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/ImpersonationTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/ImpersonationTest.java index 932568dc11a..a9160cd6b74 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/ImpersonationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/ImpersonationTest.java @@ -305,7 +305,7 @@ public class ImpersonationTest { EventRepresentation event = events.poll(); Assertions.assertEquals(event.getType(), EventType.IMPERSONATE.toString()); - MatcherAssert.assertThat(event.getSessionId(), EventMatchers.isUUID()); + MatcherAssert.assertThat(event.getSessionId(), EventMatchers.isSessionId()); Assertions.assertEquals(event.getUserId(), managedUser.getId()); Assertions.assertTrue(event.getDetails().values().stream().anyMatch(f -> f.equals(admin))); Assertions.assertTrue(event.getDetails().values().stream().anyMatch(f -> f.equals(adminRealm))); 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 e5e7e891f6b..4469ed70765 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 @@ -47,6 +47,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import static org.hamcrest.Matchers.emptyOrNullString; import static org.hamcrest.Matchers.is; @@ -118,7 +119,7 @@ public class AssertEvents implements TestRule { //.detail(Details.AUTH_TYPE, AuthorizationEndpoint.CODE_AUTH_TYPE) .detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI)) .detail(Details.CONSENT, Details.CONSENT_VALUE_NO_CONSENT_REQUIRED) - .session(isUUID()); + .session(isSessionId()); } public ExpectedEvent expectClientLogin() { @@ -127,7 +128,7 @@ public class AssertEvents implements TestRule { .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .detail(Details.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS) .removeDetail(Details.CODE_ID) - .session(isUUID()); + .session(isSessionId()); } public ExpectedEvent expectSocialLogin() { @@ -136,14 +137,14 @@ public class AssertEvents implements TestRule { .detail(Details.USERNAME, DEFAULT_USERNAME) .detail(Details.AUTH_METHOD, "form") .detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI)) - .session(isUUID()); + .session(isSessionId()); } public ExpectedEvent expectCodeToToken(String codeId, String sessionId) { return expect(EventType.CODE_TO_TOKEN) .detail(Details.CODE_ID, codeId) .detail(Details.TOKEN_ID, isAccessTokenId(AuthorizationCodeGrantTypeFactory.GRANT_SHORTCUT)) - .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .session(sessionId); @@ -153,7 +154,7 @@ public class AssertEvents implements TestRule { return expect(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE) .user((String) null) .client(clientId) - .detail(Details.CODE_ID, isUUID()); + .detail(Details.CODE_ID, isCodeId()); } public ExpectedEvent expectDeviceLogin(String clientId, String codeId, String userId) { @@ -171,7 +172,7 @@ public class AssertEvents implements TestRule { .user(userId) .detail(Details.CODE_ID, codeId) .detail(Details.TOKEN_ID, isAccessTokenId(DeviceGrantTypeFactory.GRANT_SHORTCUT)) - .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .session(codeId); @@ -182,7 +183,7 @@ public class AssertEvents implements TestRule { .detail(Details.TOKEN_ID, isAccessTokenId(RefreshTokenGrantTypeFactory.GRANT_SHORTCUT)) .detail(Details.REFRESH_TOKEN_ID, refreshTokenId) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, isUUID()) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, isTokenId()) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) .session(sessionId); } @@ -242,10 +243,10 @@ public class AssertEvents implements TestRule { return expect(EventType.AUTHREQID_TO_TOKEN) .detail(Details.CODE_ID, codeId) .detail(Details.TOKEN_ID, isAccessTokenId(CibaGrantTypeFactory.GRANT_SHORTCUT)) - .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) - .session(isUUID()); + .session(isSessionId()); } public ExpectedEvent expectClientPolicyError(EventType eventType, String error, String reason, String clientPolicyError, String clientPolicyErrorDetail) { @@ -466,7 +467,34 @@ public class AssertEvents implements TestRule { } public static Matcher isCodeId() { - return isUUID(); + // Make the tests pass with the old and the new encoding of code IDs + return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID()); + } + + public static Matcher isSessionId() { + // Make the tests pass with the old and the new encoding of sessions + return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID()); + } + + public static Matcher isTokenId() { + // Make the tests pass with the old and the new encoding of token IDs + return Matchers.anyOf(isBase64WithAtLeast128Bits(), isUUID()); + } + + public static Matcher isBase64WithAtLeast128Bits() { + return new TypeSafeMatcher<>() { + private static final Pattern BASE64 = Pattern.compile("[-A-Za-z0-9+/_]*"); + + @Override + protected boolean matchesSafely(String item) { + return item.length() >= 24 && item.matches(BASE64.pattern()); + } + + @Override + public void describeTo(Description description) { + description.appendText("not an base64 ID with at least 128bits"); + } + }; } public static Matcher isUUID() { @@ -491,7 +519,7 @@ public class AssertEvents implements TestRule { if (items.length != 2) return false; // Grant type shortcut starts at character 4th char and is 2-chars long if (items[0].substring(3, 5).equals(expectedGrantShortcut)) return false; - return isUUID().matches(items[1]); + return isTokenId().matches(items[1]); } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionTotpSetupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionTotpSetupTest.java index f31723cbceb..3c869730970 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionTotpSetupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionTotpSetupTest.java @@ -587,7 +587,7 @@ public class AppInitiatedActionTotpSetupTest extends AbstractAppInitiatedActionT tokenResponse = sendTokenRequestAndGetResponse(loginEvent); oauth.logoutForm().idTokenHint(tokenResponse.getIdToken()).withRedirect().open(); - events.expectLogout(null).session(AssertEvents.isUUID()).assertEvent(); + events.expectLogout(null).session(AssertEvents.isSessionId()).assertEvent(); // test lookAheadWindow realmRep = adminClient.realm("test").toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java index 09b6a1365bc..ee7b76a45a2 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionTotpSetupTest.java @@ -674,7 +674,7 @@ public class RequiredActionTotpSetupTest extends AbstractTestRealmKeycloakTest { tokenResponse = sendTokenRequestAndGetResponse(loginEvent); oauth.logoutForm().idTokenHint(tokenResponse.getIdToken()).withRedirect().open(); - events.expectLogout(null).session(AssertEvents.isUUID()).assertEvent(); + events.expectLogout(null).session(AssertEvents.isSessionId()).assertEvent(); // test lookAheadWindow realmRep = adminClient.realm("test").toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java index 7c2b159dc58..a97a18762f0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java @@ -2933,7 +2933,7 @@ public class CIBATest extends AbstractClientPoliciesTest { else assertThat(tokenRes.getErrorDescription(), is(equalTo("Session not active"))); RefreshToken rt = oauth.parseRefreshToken(refreshToken); - return events.expectLogout(sessionId).client(TEST_CLIENT_NAME).user(rt.getSubject()).session(AssertEvents.isUUID()).clearDetails().assertEvent(); + return events.expectLogout(sessionId).client(TEST_CLIENT_NAME).user(rt.getSubject()).session(AssertEvents.isSessionId()).clearDetails().assertEvent(); } private EventRepresentation doTokenRevokeByRefreshToken(String refreshToken, String sessionId, String userId, boolean isOfflineAccess) throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java index bae12561c1b..684b6aeab33 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java @@ -26,6 +26,7 @@ import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.Response; import org.apache.commons.lang3.RandomStringUtils; +import org.hamcrest.MatcherAssert; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Rule; @@ -47,7 +48,6 @@ import org.keycloak.models.ClientScopeModel; import org.keycloak.models.Constants; import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.credential.PasswordCredentialModel; -import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.idm.ClientRepresentation; @@ -1100,7 +1100,7 @@ public class LoginTest extends AbstractChangeImportedUserPasswordsTest { String authSessionId = decodedAuthSessionId.substring(0, decodedAuthSessionId.indexOf(".")); String signature = decodedAuthSessionId.substring(decodedAuthSessionId.indexOf(".") + 1); Assert.assertNotNull(authSessionId); - Assert.assertTrue(KeycloakModelUtils.isValidUUID(authSessionId)); + MatcherAssert.assertThat(authSessionId, AssertEvents.isSessionId()); Assert.assertNotNull(signature); testingClient.server().run(session-> { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java index 0eff4bbd908..ddec9030a5b 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AuthorizationCodeTest.java @@ -296,7 +296,7 @@ public class AuthorizationCodeTest extends AbstractKeycloakTest { events.expect(EventType.LOGIN) .user(AssertEvents.isUUID()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.USERNAME, "test-user@localhost") .detail(OIDCLoginProtocol.RESPONSE_MODE_PARAM, OIDCResponseMode.FORM_POST.name().toLowerCase()) .detail(OAuth2Constants.REDIRECT_URI, redirectUri) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java index be4393368a9..4322d26f70a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java @@ -21,7 +21,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.keycloak.testsuite.AssertEvents.isUUID; +import static org.keycloak.testsuite.AssertEvents.isTokenId; import static org.keycloak.testsuite.AbstractAdminTest.loadJson; import java.io.IOException; @@ -318,7 +318,7 @@ public class TokenRevocationTest extends AbstractKeycloakTest { events.expect(EventType.REVOKE_GRANT) .session(tokenResponse.getSessionState()) - .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, expectedTokenType) .client("test-app") .assertEvent(true); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/hok/HoKTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/hok/HoKTest.java index 4509fea31f8..e1af63626b0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/hok/HoKTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/hok/HoKTest.java @@ -103,7 +103,7 @@ public class HoKTest extends AbstractTestRealmKeycloakTest { //.detail(Details.AUTH_TYPE, AuthorizationEndpoint.CODE_AUTH_TYPE) .detail(Details.REDIRECT_URI, defaultRedirectUri) .detail(Details.CONSENT, Details.CONSENT_VALUE_NO_CONSENT_REQUIRED) - .session(isUUID()); + .session(isSessionId()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index e45dba54a23..35c39f7f3cb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -227,7 +227,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.INVALID_REQUEST) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "requested_token_type unsupported") .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE) .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") @@ -252,7 +252,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { events.expect(EventType.TOKEN_EXCHANGE) .client("requester-client") .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE) .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") .assertEvent(); @@ -265,7 +265,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.INVALID_REQUEST) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "requested_token_type unsupported") .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE) .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") @@ -279,7 +279,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.INVALID_REQUEST) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "requested_token_type unsupported") .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.SAML2_TOKEN_TYPE) .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") @@ -293,7 +293,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.INVALID_REQUEST) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "requested_token_type unsupported") .detail(Details.REQUESTED_TOKEN_TYPE, "WRONG_TOKEN_TYPE") .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") @@ -329,7 +329,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("invalid-requester-client") .error(Errors.NOT_ALLOWED) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "client is not within the token audience") .assertEvent(); } @@ -742,7 +742,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.INVALID_REQUEST) .user(john.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "Requested audience not available: target-client2") .assertEvent(); @@ -788,9 +788,9 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { AccessToken exchangedToken = assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); events.expect(EventType.REFRESH_TOKEN) .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isTokenId()) .session(exchangedToken.getSessionId()); oauth.client("requester-client", "secret"); @@ -798,9 +798,9 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { exchangedToken = assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); events.expect(EventType.REFRESH_TOKEN) .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isTokenId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isTokenId()) .session(exchangedToken.getSessionId()); } } @@ -844,7 +844,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.CONSENT_DENIED) .user(mike.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") .assertEvent(); @@ -866,7 +866,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { .client("requester-client") .error(Errors.CONSENT_DENIED) .user(mike.getId()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") .assertEvent(); @@ -1221,7 +1221,7 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { assertTrue(rep.isActive()); events.expect(EventType.INTROSPECT_TOKEN) .user(AssertEvents.isUUID()) - .session(AssertEvents.isUUID()) + .session(AssertEvents.isSessionId()) .client(clientId) .assertEvent(); } 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 96544e7bffc..f969dbcef0f 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 @@ -826,7 +826,7 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest { int pageCount = 0; boolean next = true; List result = new ArrayList<>(); - String lastSessionId = "00000000-0000-0000-0000-000000000000"; + String lastSessionId = ""; while (next) { List sess = persister