Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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;
Expand Down Expand Up @@ -93,4 +94,19 @@ public boolean removeExpired(KeycloakSession session, String realmId, int curren

return hasMore;
}

/**
* Checks with a single index lookup if the realm has any 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.
*/
@Override
public boolean hasWork(KeycloakSession session, String realmId) {
return !session.getProvider(JpaConnectionProvider.class).getEntityManager()
.createNamedQuery("findAnyRootAuthSessionIdByRealm", String.class)
.setParameter("realmId", realmId)
.setHint(AvailableHints.HINT_READ_ONLY, true)
.setMaxResults(1)
.getResultList()
.isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@
@FunctionalInterface
public interface ExpirationAction {

/**
* Cheap read-only check if there might be anything to remove for the realm.
* <p>
* Realm-aware tasks call this in a transaction before they claim the realm for the current interval. If it returns
* {@code false}, the realm is skipped without writing anything, so it is checked again on the next run.
*
* @param session the current Keycloak session, valid for the duration of the enclosing transaction.
* @param realmId the realm to check.
* @return {@code false} if the realm has nothing that could expire; {@code true} otherwise. Defaults to {@code true}.
*/
default boolean hasWork(KeycloakSession session, String realmId) {
return true;
}

/**
* Removes expired entries from the database.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,20 @@ private void cleanupOrphans(RealmsInfo realmsInfo) {

private boolean realmNeedsCleanup(String realmId, int currentTime) {
try {
return KeycloakModelUtils.runJobInTransactionWithResult(factory, session -> {
var config = session.getProvider(ServerConfigStorageProvider.class);
var key = rowIdForRealm(realmId);
var lastCheck = config.loadOrCreate(key, "0");
if (Long.parseLong(lastCheck) + intervalSeconds > currentTime) {
return false;
var key = rowIdForRealm(realmId);
var lastCheck = KeycloakModelUtils.runJobInTransactionWithResult(factory, session -> {
var value = session.getProvider(ServerConfigStorageProvider.class).loadOrCreate(key, "0");
// the interval has not elapsed, or there is nothing to do: don't write the coordination row
if (Long.parseLong(value) + intervalSeconds > currentTime || !action.hasWork(session, realmId)) {
return null;
}
return config.replace(key, lastCheck, Long.toString(currentTime));
return value;
});
if (lastCheck == null) {
return false;
}
return KeycloakModelUtils.runJobInTransactionWithResult(factory, session ->
session.getProvider(ServerConfigStorageProvider.class).replace(key, lastCheck, Long.toString(currentTime)));
Comment on lines +129 to +130
} catch (Exception e) {
logger.debugf(e, "Unable to update realm last cleanup check. Skipping removal of expired '%s' for realm '%s'", entityId, realmId);
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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 org.keycloak.authentication.jpa.AuthenticationSessionExpirationAction;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
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 static org.junit.jupiter.api.Assertions.assertFalse;
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 that the authentication session expiration only has work for realms with authentication sessions.
*/
@KeycloakIntegrationTest
public class AuthenticationSessionExpirationTest {

private static final String REALM_NAME = "auth-expiration-work";

@InjectRealm(config = ExpirationRealmConfig.class)
ManagedRealm realm;

@TestOnServer
public void emptyRealmHasNoWork(KeycloakSession session) {
assumeTrue(Profile.isFeatureEnabled(Profile.Feature.STATELESS));
String realmId = session.realms().getRealmByName(REALM_NAME).getId();

assertFalse(hasWork(session, realmId));
}

@TestOnServer
public void sessionInPreviouslyEmptyRealmIsStillExpired(KeycloakSession session) {
assumeTrue(Profile.isFeatureEnabled(Profile.Feature.STATELESS));
RealmModel realmModel = session.realms().getRealmByName(REALM_NAME);
String realmId = realmModel.getId();
InfinispanTimeUtil.enableTestingTimeService(session);
try {
assertFalse(hasWork(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();
});

assertTrue(hasWork(session, realmId));

Time.setOffset(SessionExpiration.getAuthSessionLifespan(realmModel) + 60);
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 ->
AuthenticationSessionExpirationAction.INSTANCE.removeExpired(session1, realmId, Time.currentTime(), Integer.MAX_VALUE, removed -> {
}));

KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session1 -> {
assertNull(session1.getProvider(JpaConnectionProvider.class).getEntityManager()

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / Build

cannot find symbol

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / Build

cannot find symbol

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / Build distribution

cannot find symbol

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / Build distribution

cannot find symbol

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / CodeQL Java

cannot find symbol

Check failure on line 83 in tests/base/src/test/java/org/keycloak/tests/model/AuthenticationSessionExpirationTest.java

View workflow job for this annotation

GitHub Actions / CodeQL Java

cannot find symbol
.find(org.keycloak.authentication.jpa.RootAuthenticationSessionEntity.class, sessionId[0]));
});
assertFalse(hasWork(session, realmId));
} finally {
Time.setOffset(0);
session.getKeycloakSessionFactory().publish(new ResetTimeOffsetEvent());
InfinispanTimeUtil.disableTestingTimeService(session);
}
}

private static boolean hasWork(KeycloakSession session, String realmId) {
return KeycloakModelUtils.runJobInTransactionWithResult(session.getKeycloakSessionFactory(), session1 ->
AuthenticationSessionExpirationAction.INSTANCE.hasWork(session1, realmId));
}

private static class ExpirationRealmConfig implements RealmConfig {
@Override
public RealmBuilder configure(RealmBuilder realm) {
return realm.name(REALM_NAME);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntConsumer;

import org.keycloak.common.util.Time;
import org.keycloak.expiration.jpa.ExpirationAction;
Expand Down Expand Up @@ -432,6 +434,38 @@ public void testConcurrencyGuardSkipsDuplicateRun(KeycloakSession session) throw
assertThat(callCount.get(), is(1));
}

// -- hasWork test --

@TestOnServer
public void testRealmAwareTaskSkipsRealmWithoutWorkAndDoesNotClaimIt(KeycloakSession session) {
var hasWork = new AtomicBoolean(false);
var removeCalls = new AtomicInteger();
var realmId = session.realms().getRealmByName(REALM_NAME_1).getId();
var action = new ExpirationAction() {
@Override
public boolean hasWork(KeycloakSession s, String id) {
return !realmId.equals(id) || hasWork.get();
}

@Override
public boolean removeExpired(KeycloakSession s, String id, int currentTime, int maxRemoval, IntConsumer removeCount) {
if (realmId.equals(id)) {
removeCalls.incrementAndGet();
}
return false;
}
};

var task = buildTask("realm-has-work", action, new RecordingListener(), true, session.getKeycloakSessionFactory());
task.run();
assertThat(removeCalls.get(), is(0));

// the realm was not claimed, so it is cleaned in the same interval as soon as there is work
hasWork.set(true);
task.run();
assertThat(removeCalls.get(), is(1));
}

// -- Helpers --

private ExpirationTask buildTask(String entityId, ExpirationAction action, ExpirationListener listener, boolean realmAware, KeycloakSessionFactory factory) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.AuthenticationSessionExpirationTest;
import org.keycloak.tests.model.AuthenticationSessionProviderTest;
import org.keycloak.tests.model.SingleUseProviderTest;
import org.keycloak.tests.model.UserSessionProviderOfflineTest;
Expand Down Expand Up @@ -41,6 +42,7 @@
KcAdmSessionTest.class,
RecoveryAuthnCodesAuthenticatorTest.class,
AsyncCommitHqlTest.class,
AuthenticationSessionExpirationTest.class,
AuthenticationSessionProviderTest.class,
SingleUseProviderTest.class,
JdbcPingReconnectCacheClearTest.class,
Expand Down
Loading