From 87fba07aa2944fbdd537bd9b63824a7e23627cb0 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Tue, 10 Nov 2026 19:52:40 +0100 Subject: [PATCH 1/2] Skip authentication session expiration for realms without authentication sessions Closes #55015 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Alexander Schwartz --- ...AuthenticationSessionExpirationAction.java | 38 +++++ .../jpa/RootAuthenticationSessionEntity.java | 4 + ...cationSessionExpirationQueryCountTest.java | 161 ++++++++++++++++++ .../tests/suites/StatelessTestSuite.java | 2 + 4 files changed, 205 insertions(+) create mode 100644 tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java diff --git a/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java b/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java index 51c8263e7267..09c65f2f1298 100644 --- a/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java +++ b/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java @@ -19,13 +19,18 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; +import jakarta.persistence.EntityManager; + import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.expiration.jpa.ExpirationAction; import org.keycloak.models.KeycloakSession; import org.keycloak.models.utils.SessionExpiration; +import org.hibernate.jpa.AvailableHints; import org.jboss.logging.Logger; import static org.keycloak.authentication.jpa.RootAuthenticationSessionEntity.SESSION_BUCKET_COUNT; @@ -35,6 +40,8 @@ public enum AuthenticationSessionExpirationAction implements ExpirationAction { private static final Logger logger = Logger.getLogger(AuthenticationSessionExpirationAction.class); + private final Map emptyObservedAt = new ConcurrentHashMap<>(); + @Override public boolean removeExpired(KeycloakSession session, String realmId, int currentTime, int maxRemoval, IntConsumer removeCount) { var realm = session.realms().getRealm(realmId); @@ -46,6 +53,10 @@ public boolean removeExpired(KeycloakSession session, String realmId, int curren var olderTimestamp = currentTime - lifespan; var em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + if (isRealmEmpty(em, realmId, currentTime, lifespan)) { + return false; + } + boolean hasMore = false; int remaining = maxRemoval; for (int bucket = 0; bucket < SESSION_BUCKET_COUNT && remaining > 0; bucket++) { @@ -93,4 +104,31 @@ public boolean removeExpired(KeycloakSession session, String realmId, int curren return hasMore; } + + /** + * Checks with a single index lookup if the realm has no authentication sessions. A session created after an empty + * observation cannot expire before the observation time plus the lifespan, so the check is remembered to skip the + * per-bucket queries until then. Only the observation time is stored and the deadline is derived from the current + * realm settings, so changing the lifespan needs no invalidation. The state is local to the node; a node without + * state performs the regular check. + */ + private boolean isRealmEmpty(EntityManager em, String realmId, int currentTime, int lifespan) { + Integer observed = emptyObservedAt.get(realmId); + // an observation in the future happens if the clock moved backwards, for example, a time offset in tests + if (observed != null && observed <= currentTime && currentTime < (long) observed + lifespan) { + return true; + } + boolean empty = em.createNamedQuery("findAnyRootAuthSessionIdByRealm", String.class) + .setParameter("realmId", realmId) + .setHint(AvailableHints.HINT_READ_ONLY, true) + .setMaxResults(1) + .getResultList() + .isEmpty(); + if (empty) { + emptyObservedAt.put(realmId, currentTime); + } else { + emptyObservedAt.remove(realmId); + } + return empty; + } } diff --git a/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java b/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java index 6c253b961d43..bb5a94dc2589 100644 --- a/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java +++ b/model/jpa/src/main/java/org/keycloak/authentication/jpa/RootAuthenticationSessionEntity.java @@ -48,6 +48,10 @@ query = "SELECT sess.id, sess.timestamp FROM RootAuthenticationSessionEntity sess" + " WHERE sess.realmId = :realmId AND sess.sessionBucket = :sessionBucket AND sess.timestampCoarse < :timestampCoarse" ), + @NamedQuery( + name = "findAnyRootAuthSessionIdByRealm", + query = "SELECT sess.id FROM RootAuthenticationSessionEntity sess WHERE sess.realmId = :realmId" + ), @NamedQuery( name = "deleteRootAuthSessionsByUser", query = "DELETE FROM RootAuthenticationSessionEntity sess" + diff --git a/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java b/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java new file mode 100644 index 000000000000..907d36dfc239 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java @@ -0,0 +1,161 @@ +/* + * 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.tests.model; + +import java.util.Arrays; + +import org.keycloak.authentication.jpa.AuthenticationSessionExpirationAction; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ResetTimeOffsetEvent; +import org.keycloak.models.utils.SessionExpiration; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.remote.annotations.TestOnServer; +import org.keycloak.testframework.remote.providers.timeoffset.InfinispanTimeUtil; + +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Checks with the Hibernate statistics which queries the authentication session expiration runs. + *

+ * The statistics are shared with the periodic expiration task running in the server. That task only runs every few + * minutes, so the assertions look at the executed query kinds and not at exact counts, and they are retried. + */ +@KeycloakIntegrationTest +public class AuthenticationSessionExpirationQueryCountTest { + + private static final String REALM_NAME = "auth-expiration-query-count"; + private static final int ASSERTION_ATTEMPTS = 3; + + @InjectRealm(config = ExpirationRealmConfig.class) + ManagedRealm realm; + + @TestOnServer + public void emptyRealmSkipsBucketQueries(KeycloakSession session) { + assumeTrue(Profile.isFeatureEnabled(Profile.Feature.STATELESS)); + String realmId = session.realms().getRealmByName(REALM_NAME).getId(); + + // the realm has no sessions, so there is no bucket query + assertWithRetries(() -> { + String[] queries = executedSessionQueries(session, realmId); + assertTrue(Arrays.stream(queries).noneMatch(q -> q.contains("sessionBucket")), + "No bucket query is expected for a realm without sessions: " + Arrays.toString(queries)); + }); + + // the realm was found empty, no session can expire yet, so the next run does not need to query anything + assertWithRetries(() -> { + String[] queries = executedSessionQueries(session, realmId); + assertEquals(0, queries.length, "Unexpected queries: " + Arrays.toString(queries)); + }); + } + + @TestOnServer + public void sessionCreatedAfterEmptyRunIsStillExpired(KeycloakSession session) { + assumeTrue(Profile.isFeatureEnabled(Profile.Feature.STATELESS)); + RealmModel realmModel = session.realms().getRealmByName(REALM_NAME); + String realmId = realmModel.getId(); + InfinispanTimeUtil.enableTestingTimeService(session); + try { + executedSessionQueries(session, realmId); + + String[] sessionId = new String[1]; + KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 -> { + RealmModel r = session1.realms().getRealm(realmId); + sessionId[0] = session1.authenticationSessions().createRootAuthenticationSession(r).getId(); + }); + + // still within the lifespan: the realm is not queried and the session survives + assertWithRetries(() -> { + String[] queries = executedSessionQueries(session, realmId); + assertEquals(0, queries.length, "Unexpected queries: " + Arrays.toString(queries)); + }); + + Time.setOffset(SessionExpiration.getAuthSessionLifespan(realmModel) + 60); + String[] afterDeadline = executedSessionQueries(session, realmId); + assertTrue(Arrays.stream(afterDeadline).anyMatch(q -> q.contains("sessionBucket")), + "Bucket queries are expected once the lifespan passed: " + Arrays.toString(afterDeadline)); + + KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 -> { + RealmModel r = session1.realms().getRealm(realmId); + assertNull(session1.authenticationSessions().getRootAuthenticationSession(r, sessionId[0])); + }); + } finally { + Time.setOffset(0); + session.getKeycloakSessionFactory().publish(new ResetTimeOffsetEvent()); + InfinispanTimeUtil.disableTestingTimeService(session); + } + } + + // The statistics are shared with the periodic expiration task, which can add queries for other realms to a measurement. + // A regression fails every attempt, so retrying only filters out that noise. + private static void assertWithRetries(Runnable assertion) { + for (int attempt = 1; ; attempt++) { + try { + assertion.run(); + return; + } catch (AssertionError e) { + if (attempt >= ASSERTION_ATTEMPTS) { + throw e; + } + } + } + } + + // runs the expiration and returns the HQL of the executed queries on the root authentication session entity + private static String[] executedSessionQueries(KeycloakSession session, String realmId) { + Statistics stats = session.getProvider(JpaConnectionProvider.class) + .getEntityManager() + .getEntityManagerFactory() + .unwrap(SessionFactory.class) + .getStatistics(); + boolean wasEnabled = stats.isStatisticsEnabled(); + stats.setStatisticsEnabled(true); + stats.clear(); + try { + KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 -> + AuthenticationSessionExpirationAction.INSTANCE.removeExpired(session1, realmId, Time.currentTime(), Integer.MAX_VALUE, removed -> { + })); + return Arrays.stream(stats.getQueries()) + .filter(q -> q.contains("RootAuthenticationSessionEntity")) + .toArray(String[]::new); + } finally { + stats.setStatisticsEnabled(wasEnabled); + } + } + + private static class ExpirationRealmConfig implements RealmConfig { + @Override + public RealmBuilder configure(RealmBuilder realm) { + return realm.name(REALM_NAME); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/suites/StatelessTestSuite.java b/tests/base/src/test/java/org/keycloak/tests/suites/StatelessTestSuite.java index c7f865f9fa5f..6682f7df2066 100644 --- a/tests/base/src/test/java/org/keycloak/tests/suites/StatelessTestSuite.java +++ b/tests/base/src/test/java/org/keycloak/tests/suites/StatelessTestSuite.java @@ -12,6 +12,7 @@ import org.keycloak.tests.db.JdbcPingReconnectCacheClearTest; import org.keycloak.tests.forms.RecoveryAuthnCodesAuthenticatorTest; import org.keycloak.tests.loginfailures.LoginFailureExpirationTest; +import org.keycloak.tests.model.AuthenticationSessionExpirationQueryCountTest; import org.keycloak.tests.model.AuthenticationSessionProviderTest; import org.keycloak.tests.model.SingleUseProviderTest; import org.keycloak.tests.model.UserSessionProviderOfflineTest; @@ -41,6 +42,7 @@ KcAdmSessionTest.class, RecoveryAuthnCodesAuthenticatorTest.class, AsyncCommitHqlTest.class, + AuthenticationSessionExpirationQueryCountTest.class, AuthenticationSessionProviderTest.class, SingleUseProviderTest.class, JdbcPingReconnectCacheClearTest.class, From 68e4508a261fd1b6d6081d00812085cd5d4e81c5 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Tue, 10 Nov 2026 21:14:07 +0100 Subject: [PATCH 2/2] Probe for authentication sessions on every expiration run instead of remembering empty realms Closes #55015 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Alexander Schwartz --- ...AuthenticationSessionExpirationAction.java | 28 ++++--------------- ...cationSessionExpirationQueryCountTest.java | 18 ++++-------- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java b/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java index 09c65f2f1298..e769b5fc1e05 100644 --- a/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java +++ b/model/jpa/src/main/java/org/keycloak/authentication/jpa/AuthenticationSessionExpirationAction.java @@ -19,8 +19,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; import jakarta.persistence.EntityManager; @@ -40,8 +38,6 @@ public enum AuthenticationSessionExpirationAction implements ExpirationAction { private static final Logger logger = Logger.getLogger(AuthenticationSessionExpirationAction.class); - private final Map emptyObservedAt = new ConcurrentHashMap<>(); - @Override public boolean removeExpired(KeycloakSession session, String realmId, int currentTime, int maxRemoval, IntConsumer removeCount) { var realm = session.realms().getRealm(realmId); @@ -53,7 +49,7 @@ public boolean removeExpired(KeycloakSession session, String realmId, int curren var olderTimestamp = currentTime - lifespan; var em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); - if (isRealmEmpty(em, realmId, currentTime, lifespan)) { + if (isRealmEmpty(em, realmId)) { return false; } @@ -106,29 +102,15 @@ public boolean removeExpired(KeycloakSession session, String realmId, int curren } /** - * Checks with a single index lookup if the realm has no authentication sessions. A session created after an empty - * observation cannot expire before the observation time plus the lifespan, so the check is remembered to skip the - * per-bucket queries until then. Only the observation time is stored and the deadline is derived from the current - * realm settings, so changing the lifespan needs no invalidation. The state is local to the node; a node without - * state performs the regular check. + * Checks with a single index lookup if the realm has no authentication sessions, to skip the per-bucket queries. + * Nothing is remembered between runs, so the result never depends on the clock or on the node running the task. */ - private boolean isRealmEmpty(EntityManager em, String realmId, int currentTime, int lifespan) { - Integer observed = emptyObservedAt.get(realmId); - // an observation in the future happens if the clock moved backwards, for example, a time offset in tests - if (observed != null && observed <= currentTime && currentTime < (long) observed + lifespan) { - return true; - } - boolean empty = em.createNamedQuery("findAnyRootAuthSessionIdByRealm", String.class) + private boolean isRealmEmpty(EntityManager em, String realmId) { + return em.createNamedQuery("findAnyRootAuthSessionIdByRealm", String.class) .setParameter("realmId", realmId) .setHint(AvailableHints.HINT_READ_ONLY, true) .setMaxResults(1) .getResultList() .isEmpty(); - if (empty) { - emptyObservedAt.put(realmId, currentTime); - } else { - emptyObservedAt.remove(realmId); - } - return empty; } } diff --git a/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java b/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java index 907d36dfc239..1e401a2c17d9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationQueryCountTest.java @@ -39,7 +39,6 @@ import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -70,16 +69,10 @@ public void emptyRealmSkipsBucketQueries(KeycloakSession session) { assertTrue(Arrays.stream(queries).noneMatch(q -> q.contains("sessionBucket")), "No bucket query is expected for a realm without sessions: " + Arrays.toString(queries)); }); - - // the realm was found empty, no session can expire yet, so the next run does not need to query anything - assertWithRetries(() -> { - String[] queries = executedSessionQueries(session, realmId); - assertEquals(0, queries.length, "Unexpected queries: " + Arrays.toString(queries)); - }); } @TestOnServer - public void sessionCreatedAfterEmptyRunIsStillExpired(KeycloakSession session) { + public void sessionInPreviouslyEmptyRealmIsStillExpired(KeycloakSession session) { assumeTrue(Profile.isFeatureEnabled(Profile.Feature.STATELESS)); RealmModel realmModel = session.realms().getRealmByName(REALM_NAME); String realmId = realmModel.getId(); @@ -93,16 +86,15 @@ public void sessionCreatedAfterEmptyRunIsStillExpired(KeycloakSession session) { sessionId[0] = session1.authenticationSessions().createRootAuthenticationSession(r).getId(); }); - // still within the lifespan: the realm is not queried and the session survives + // the realm has a session now, so the probe finds it and the bucket queries run assertWithRetries(() -> { String[] queries = executedSessionQueries(session, realmId); - assertEquals(0, queries.length, "Unexpected queries: " + Arrays.toString(queries)); + assertTrue(Arrays.stream(queries).anyMatch(q -> q.contains("sessionBucket")), + "Bucket queries are expected for a realm with sessions: " + Arrays.toString(queries)); }); Time.setOffset(SessionExpiration.getAuthSessionLifespan(realmModel) + 60); - String[] afterDeadline = executedSessionQueries(session, realmId); - assertTrue(Arrays.stream(afterDeadline).anyMatch(q -> q.contains("sessionBucket")), - "Bucket queries are expected once the lifespan passed: " + Arrays.toString(afterDeadline)); + executedSessionQueries(session, realmId); KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 -> { RealmModel r = session1.realms().getRealm(realmId);