Create user session expired event

Closes #43942

Signed-off-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
Co-authored-by: Pedro Ruivo <1492066+pruivo@users.noreply.github.com>
Co-authored-by: Alexander Schwartz <alexander.schwartz@ibm.com>
This commit is contained in:
Pedro Ruivo
2025-11-07 22:36:47 +00:00
committed by GitHub
parent 80895d7fb4
commit 18eeef7b26
22 changed files with 681 additions and 210 deletions

View File

@@ -53,6 +53,7 @@ import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity;
import org.keycloak.models.sessions.infinispan.events.AbstractUserSessionClusterListener;
import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent;
import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent;
import org.keycloak.models.sessions.infinispan.listeners.EmbeddedUserSessionExpirationListener;
import org.keycloak.models.sessions.infinispan.transaction.InfinispanTransactionProvider;
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;
import org.keycloak.models.utils.KeycloakModelUtils;
@@ -89,6 +90,7 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
private CacheHolder<String, UserSessionEntity> offlineSessionCacheHolder;
private CacheHolder<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> clientSessionCacheHolder;
private CacheHolder<EmbeddedClientSessionKey, AuthenticatedClientSessionEntity> offlineClientSessionCacheHolder;
private EmbeddedUserSessionExpirationListener expirationListener;
private long offlineSessionCacheEntryLifespanOverride;
@@ -203,7 +205,12 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
offlineSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_USER_SESSION_CACHE_NAME, this::deriveOfflineSessionCacheEntryLifespanMs, SessionTimeouts::getOfflineSessionMaxIdleMs);
clientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, CLIENT_SESSION_CACHE_NAME, SessionTimeouts::getClientSessionLifespanMs, SessionTimeouts::getClientSessionMaxIdleMs);
offlineClientSessionCacheHolder = InfinispanChangesUtils.createWithCache(session, OFFLINE_CLIENT_SESSION_CACHE_NAME, this::deriveOfflineClientSessionCacheEntryLifespanOverrideMs, SessionTimeouts::getOfflineClientSessionMaxIdleMs);
var blockingManager = session.getProvider(InfinispanConnectionProvider.class).getBlockingManager();
expirationListener = new EmbeddedUserSessionExpirationListener(session.getKeycloakSessionFactory(), blockingManager);
}
// Only add the event listener to session caches
// The expired events for offline sessions will be triggered by JpaUserSessionPersisterProvider
sessionCacheHolder.cache().addListener(expirationListener);
}
}
@@ -291,6 +298,10 @@ public class InfinispanUserSessionProviderFactory implements UserSessionProvider
if (persistentSessionsWorker != null) {
persistentSessionsWorker.stop();
}
if (expirationListener != null) {
sessionCacheHolder.cache().removeListener(expirationListener);
expirationListener = null;
}
}
@Override

View File

@@ -75,14 +75,24 @@ public abstract class BaseUpdater<K, V> implements Updater<K, V> {
return state == UpdaterState.READ && isUnchanged();
}
@Override
public final boolean isExpired() {
return state == UpdaterState.EXPIRED;
}
@Override
public final void markDeleted() {
state = switch (state) {
case READ, DELETED -> UpdaterState.DELETED;
case CREATED, DELETED_TRANSIENT -> UpdaterState.DELETED_TRANSIENT;
case CREATED, DELETED_TRANSIENT, EXPIRED -> UpdaterState.DELETED_TRANSIENT;
};
}
@Override
public void markExpired() {
state = UpdaterState.EXPIRED;
}
@Override
public boolean isTransient() {
return state == UpdaterState.DELETED_TRANSIENT;
@@ -141,5 +151,10 @@ public abstract class BaseUpdater<K, V> implements Updater<K, V> {
* The entity is transient (it won't be updated in the external infinispan cluster) and deleted.
*/
DELETED_TRANSIENT,
/**
* The entity is expired (max-idle or lifespan). No changes should be applied.
*/
EXPIRED,
}
}

View File

@@ -65,11 +65,28 @@ public interface Updater<K, V> extends BiFunction<K, V, V> {
*/
boolean isReadOnly();
/**
* @return {@code true} if the entity is expired.
*/
boolean isExpired();
/**
* @return {@code true} if the entity is not valid and cannot be viewed/accessed from the transaction.
*/
default boolean isInvalid() {
return isExpired() || isDeleted();
}
/**
* Marks the entity as deleted.
*/
void markDeleted();
/**
* Marks the entity as expired when loading from the Infinispan cache.
*/
void markExpired();
/**
* @return {@code true} if the entity is transient and shouldn't be stored in the Infinispan cache.
*/

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.sessions.infinispan.listeners;
import java.lang.invoke.MethodHandles;
import org.infinispan.util.concurrent.BlockingManager;
import org.jboss.logging.Logger;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
/**
* Base class to handle expired user session.
* <p>
* It offloads the event creating and sending to a different thread to avoid blocking the caller.
*/
abstract class BaseUserSessionExpirationListener {
protected static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
private final KeycloakSessionFactory factory;
private final BlockingManager blockingManager;
BaseUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager) {
this.factory = factory;
this.blockingManager = blockingManager;
}
protected void sendExpirationEvent(String userSessionId, String userId, String realmId) {
blockingManager.runBlocking(() -> doSend(userSessionId, userId, realmId), "expired-" + userSessionId);
}
private void doSend(String userSessionId, String userId, String realmId) {
KeycloakModelUtils.runJobInTransaction(factory, session -> {
logger.debugf("User session expired. sessionId=%s, userId=%s, realmId=%s", userSessionId, userId, realmId);
RealmModel realm = session.realms().getRealm(realmId);
if (realm == null) {
return;
}
session.getContext().setRealm(realm);
new EventBuilder(realm, session)
.session(userSessionId)
.user(userId)
.event(EventType.USER_SESSION_DELETED)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.success();
});
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.sessions.infinispan.listeners;
import org.infinispan.notifications.Listener;
import org.infinispan.notifications.cachelistener.annotation.CacheEntryExpired;
import org.infinispan.notifications.cachelistener.event.CacheEntryExpiredEvent;
import org.infinispan.util.concurrent.BlockingManager;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.sessions.infinispan.changes.SessionEntityWrapper;
import org.keycloak.models.sessions.infinispan.entities.UserSessionEntity;
/**
* A listener for embedded Infinispan caches.
* <p>
* It listens to the {@link CacheEntryExpired} events for user sessions.
*/
@Listener(primaryOnly = true)
public class EmbeddedUserSessionExpirationListener extends BaseUserSessionExpirationListener {
public EmbeddedUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager) {
super(factory, blockingManager);
}
@CacheEntryExpired
public void onSessionExpired(CacheEntryExpiredEvent<?, SessionEntityWrapper<UserSessionEntity>> event) {
UserSessionEntity entity = event.getValue().getEntity();
sendExpirationEvent(entity.getId(), entity.getUser(), entity.getRealmId());
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.sessions.infinispan.listeners;
import java.io.IOException;
import java.nio.ByteBuffer;
import org.infinispan.client.hotrod.annotation.ClientCacheEntryExpired;
import org.infinispan.client.hotrod.annotation.ClientListener;
import org.infinispan.client.hotrod.event.ClientCacheEntryCustomEvent;
import org.infinispan.commons.io.UnsignedNumeric;
import org.infinispan.commons.marshall.Marshaller;
import org.infinispan.util.concurrent.BlockingManager;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity;
/**
* A listener for remote Infinispan caches.
* <p>
* It listens to the {@link ClientCacheEntryExpired} events for user sessions.
*/
@ClientListener(converterFactoryName = "___eager-key-value-version-converter", useRawData = true)
public class RemoteUserSessionExpirationListener extends BaseUserSessionExpirationListener {
private final Marshaller marshaller;
public RemoteUserSessionExpirationListener(KeycloakSessionFactory factory, BlockingManager blockingManager, Marshaller marshaller) {
super(factory, blockingManager);
this.marshaller = marshaller;
}
@ClientCacheEntryExpired
public void onSessionExpired(ClientCacheEntryCustomEvent<byte[]> entryExpired) {
try {
RemoteUserSessionEntity entity = extractRemoteUserSessionEntity(entryExpired);
if (entity == null) {
return;
}
sendExpirationEvent(entity.getUserSessionId(), entity.getUserId(), entity.getRealmId());
} catch (Exception e) {
logger.error("Error handling an expired entry", e);
}
}
private RemoteUserSessionEntity extractRemoteUserSessionEntity(ClientCacheEntryCustomEvent<byte[]> event) throws IOException, ClassNotFoundException {
ByteBuffer rawData = ByteBuffer.wrap(event.getEventData());
// skip the key, we don't need it
skipElement(rawData);
// read the value
Object value = marshaller.objectFromByteBuffer(readElement(rawData));
return value instanceof RemoteUserSessionEntity ruse ? ruse : null;
}
private static void skipElement(ByteBuffer buffer) {
int length = UnsignedNumeric.readUnsignedInt(buffer);
buffer.position(buffer.position() + length);
}
private static byte[] readElement(ByteBuffer buffer) {
int length = UnsignedNumeric.readUnsignedInt(buffer);
byte[] element = new byte[length];
buffer.get(element);
return element;
}
}

View File

@@ -369,7 +369,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider {
private UserSessionUpdater initUserSessionFromQuery(UserSessionUpdater updater, RealmModel realm, UserModel user, boolean offline) {
assert updater != null;
assert realm != null;
if (updater.isDeleted()) {
if (updater.isInvalid()) {
return null;
}
if (updater.isInitialized()) {
@@ -408,7 +408,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider {
}
private AuthenticatedClientSessionModel initClientSessionUpdater(AuthenticatedClientSessionUpdater updater, UserSessionUpdater userSession) {
if (updater == null || updater.isDeleted()) {
if (updater == null || updater.isInvalid()) {
return null;
}
var client = userSession.getRealm().getClientById(updater.getKey().clientId());
@@ -464,7 +464,7 @@ public class RemoteUserSessionProvider implements UserSessionProvider {
private static <K, V, T extends BaseUpdater<K, V>> T checkExpiration(T updater) {
var expiration = updater.computeExpiration();
if (expiration.isExpired()) {
updater.markDeleted();
updater.markExpired();
return null;
}
return updater;

View File

@@ -22,6 +22,7 @@ import org.keycloak.models.sessions.infinispan.changes.remote.updater.user.UserS
import org.keycloak.models.sessions.infinispan.entities.ClientSessionKey;
import org.keycloak.models.sessions.infinispan.entities.RemoteAuthenticatedClientSessionEntity;
import org.keycloak.models.sessions.infinispan.entities.RemoteUserSessionEntity;
import org.keycloak.models.sessions.infinispan.listeners.RemoteUserSessionExpirationListener;
import org.keycloak.models.sessions.infinispan.remote.transaction.ClientSessionChangeLogTransaction;
import org.keycloak.models.sessions.infinispan.remote.transaction.RemoteChangeLogTransaction;
import org.keycloak.models.sessions.infinispan.remote.transaction.UserSessionChangeLogTransaction;
@@ -54,6 +55,7 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact
private volatile int batchSize = DEFAULT_BATCH_SIZE;
private volatile int maxRetries = InfinispanUtils.DEFAULT_MAX_RETRIES;
private volatile int backOffBaseTimeMillis = InfinispanUtils.DEFAULT_RETRIES_BASE_TIME_MILLIS;
private volatile RemoteUserSessionExpirationListener expirationListener;
@Override
public RemoteUserSessionProvider create(KeycloakSession session) {
@@ -80,6 +82,11 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact
@Override
public void close() {
if (expirationListener != null) {
userSessionState.cache().removeClientListener(expirationListener);
offlineUserSessionState.cache().removeClientListener(expirationListener);
}
expirationListener = null;
blockingManager = null;
userSessionState = null;
offlineUserSessionState = null;
@@ -131,7 +138,7 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact
@Override
public Set<Class<? extends Provider>> dependsOn() {
return Set.of(InfinispanTransactionProvider.class);
return Set.of(InfinispanTransactionProvider.class, InfinispanConnectionProvider.class);
}
private void onUserRemoved(UserModel.UserRemovedEvent event) {
@@ -149,6 +156,9 @@ public class RemoteUserSessionProviderFactory implements UserSessionProviderFact
clientSessionState = new SharedStateImpl<>(connections.getRemoteCache(CLIENT_SESSION_CACHE_NAME));
offlineClientSessionState = new SharedStateImpl<>(connections.getRemoteCache(OFFLINE_CLIENT_SESSION_CACHE_NAME));
blockingManager = connections.getBlockingManager();
expirationListener = new RemoteUserSessionExpirationListener(session.getKeycloakSessionFactory(), blockingManager, userSessionState.cache().getRemoteCacheContainer().getMarshaller());
userSessionState.cache().addClientListener(expirationListener);
offlineUserSessionState.cache().addClientListener(expirationListener);
}
private UserSessionTransaction createTransaction(KeycloakSession session) {

View File

@@ -68,7 +68,7 @@ public class RemoteChangeLogTransaction<K, V, T extends Updater<K, V>, R extends
conditionalRemover.executeRemovals(getCache(), stage);
for (var updater : entityChanges.values()) {
if (updater.isReadOnly() || updater.isTransient() || conditionalRemover.willRemove(updater)) {
if (updater.isReadOnly() || updater.isExpired() || updater.isTransient() || conditionalRemover.willRemove(updater)) {
continue;
}
if (updater.isDeleted()) {
@@ -79,7 +79,7 @@ public class RemoteChangeLogTransaction<K, V, T extends Updater<K, V>, R extends
var expiration = updater.computeExpiration();
if (expiration.isExpired()) {
stage.dependsOn(commitRemove(updater));
// We need the cache entry expired events from the server, do nothing here.
continue;
}
@@ -121,7 +121,7 @@ public class RemoteChangeLogTransaction<K, V, T extends Updater<K, V>, R extends
public T get(K key) {
var updater = entityChanges.get(key);
if (updater != null) {
return updater.isDeleted() ? null : updater;
return updater.isInvalid() ? null : updater;
}
return onEntityFromCache(key, getCache().getWithMetadata(key));
}
@@ -135,7 +135,7 @@ public class RemoteChangeLogTransaction<K, V, T extends Updater<K, V>, R extends
public CompletionStage<T> getAsync(K key) {
var updater = entityChanges.get(key);
if (updater != null) {
return updater.isDeleted() ? CompletableFutures.completedNull() : CompletableFuture.completedFuture(updater);
return updater.isInvalid() ? CompletableFutures.completedNull() : CompletableFuture.completedFuture(updater);
}
return getCache().getWithMetadataAsync(key).thenApply(e -> onEntityFromCache(key, e));
}
@@ -189,7 +189,7 @@ public class RemoteChangeLogTransaction<K, V, T extends Updater<K, V>, R extends
}
var updater = factory.wrapFromCache(key, entity);
entityChanges.put(key, updater);
return updater.isDeleted() ? null : updater;
return updater.isInvalid() ? null : updater;
}
@SuppressWarnings("unchecked")

View File

@@ -17,9 +17,13 @@
package org.keycloak.models.jpa.session;
import org.hibernate.jpa.AvailableHints;
import org.jboss.logging.Logger;
import org.keycloak.common.util.MultiSiteUtils;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@@ -259,19 +263,32 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv
logger.tracef("Trigger removing expired user sessions for realm '%s'", realm.getName());
int cs = em.createNamedQuery("deleteExpiredClientSessions")
TypedQuery<Object[]> query = em.createNamedQuery("findExpiredUserSessions", Object[].class)
.setParameter("realmId", realm.getId())
.setParameter("lastSessionRefresh", expired)
.setParameter("offline", offlineStr)
.executeUpdate();
.setHint(AvailableHints.HINT_READ_ONLY, true);
int us = em.createNamedQuery("deleteExpiredUserSessions")
.setParameter("realmId", realm.getId())
.setParameter("lastSessionRefresh", expired)
.setParameter("offline", offlineStr)
.executeUpdate();
var expiredSessions = query.getResultStream()
.map(JpaUserSessionPersisterProvider::userSessionAndUserProjection)
.toList();
sendExpirationEvents(realm, expiredSessions);
StreamsUtil.chunkedStream(expiredSessions.stream().map(UserSessionAndUser::userSessionId), 100).forEach(chunk -> {
// SQL databases only allow a limited number of items in an IN clause. While PostgreSQL allows possibly 32k, we are not sure about the rest
int cs = em.createNamedQuery("deleteClientSessionsByUserSessions")
.setParameter("userSessionId", chunk)
.setParameter("offline", offlineStr)
.executeUpdate();
int us = em.createNamedQuery("deleteUserSessions")
.setParameter("userSessionId", chunk)
.setParameter("offline", offlineStr)
.executeUpdate();
logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName());
});
logger.debugf("Removed %d expired user sessions and %d expired client sessions in realm '%s'", us, cs, realm.getName());
}
@Override
@@ -302,12 +319,12 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv
String offlineStr = offlineToString(offline);
TypedQuery<PersistentUserSessionEntity> userSessionQuery = em.createNamedQuery("findUserSession", PersistentUserSessionEntity.class);
userSessionQuery.setParameter("realmId", realm.getId());
userSessionQuery.setParameter("offline", offlineStr);
userSessionQuery.setParameter("userSessionId", userSessionId);
userSessionQuery.setParameter("lastSessionRefresh", calculateOldestSessionTime(realm, offline));
userSessionQuery.setMaxResults(1);
TypedQuery<PersistentUserSessionEntity> userSessionQuery = em.createNamedQuery("findUserSession", PersistentUserSessionEntity.class)
.setParameter("realmId", realm.getId())
.setParameter("offline", offlineStr)
.setParameter("userSessionId", userSessionId)
.setParameter("lastSessionRefresh", calculateOldestSessionTime(realm, offline))
.setMaxResults(1);
return handleSingleQuery(userSessionQuery, offlineStr);
}
@@ -740,7 +757,26 @@ public class JpaUserSessionPersisterProvider implements UserSessionPersisterProv
);
}
private void sendExpirationEvents(RealmModel realm, List<UserSessionAndUser> expiredSessions) {
expiredSessions.forEach(sessionAndUser -> new EventBuilder(realm, session)
.user(sessionAndUser.userId())
.session(sessionAndUser.userSessionId())
.event(EventType.USER_SESSION_DELETED)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.success());
}
private static boolean hasClient(PersistentAuthenticatedClientSessionAdapter clientSession) {
return clientSession.getClient() != null;
}
private record UserSessionAndUser(String userSessionId, String userId) {
}
private static UserSessionAndUser userSessionAndUserProjection(Object[] projection) {
assert projection.length == 2;
assert projection[0] != null;
assert projection[1] != null;
return new UserSessionAndUser(String.valueOf(projection[0]), String.valueOf(projection[1]));
}
}

View File

@@ -28,6 +28,7 @@ import jakarta.persistence.Version;
import org.hibernate.annotations.DynamicUpdate;
import java.io.Serializable;
import java.util.Objects;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@@ -39,6 +40,8 @@ import java.io.Serializable;
@NamedQuery(name="deleteClientSessionsByClientStorageProvider", query="delete from PersistentClientSessionEntity sess where sess.clientStorageProvider = :clientStorageProvider"),
@NamedQuery(name="deleteClientSessionsByUser", query="delete from PersistentClientSessionEntity sess where sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.userId = :userId)"),
@NamedQuery(name="deleteClientSessionsByUserSession", query="delete from PersistentClientSessionEntity sess where sess.userSessionId = :userSessionId and sess.offline = :offline"),
@NamedQuery(name="deleteClientSessionsByUserSessions", query="delete from PersistentClientSessionEntity sess where sess.userSessionId in (:userSessionId) and sess.offline = :offline"),
// The query "deleteExpiredClientSessions" is deprecated (since 26.5) and may be removed in the future.
@NamedQuery(name="deleteExpiredClientSessions", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId AND u.offline = :offline AND u.lastSessionRefresh < :lastSessionRefresh)"),
@NamedQuery(name="deleteClientSessionsByRealmSessionType", query="delete from PersistentClientSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (select u.userSessionId from PersistentUserSessionEntity u where u.realmId = :realmId and u.offline = :offline)"),
@NamedQuery(name="findClientSessionsByUserSession", query="select sess from PersistentClientSessionEntity sess where sess.userSessionId=:userSessionId and sess.offline = :offline"),
@@ -191,13 +194,11 @@ public class PersistentClientSessionEntity {
Key key = (Key) o;
if (this.userSessionId != null ? !this.userSessionId.equals(key.userSessionId) : key.userSessionId != null) return false;
if (this.clientId != null ? !this.clientId.equals(key.clientId) : key.clientId != null) return false;
if (this.externalClientId != null ? !this.externalClientId.equals(key.externalClientId) : key.externalClientId != null) return false;
if (this.clientStorageProvider != null ? !this.clientStorageProvider.equals(key.clientStorageProvider) : key.clientStorageProvider != null) return false;
if (this.offline != null ? !this.offline.equals(key.offline) : key.offline != null) return false;
return true;
return Objects.equals(this.userSessionId, key.userSessionId) &&
Objects.equals(this.clientId, key.clientId) &&
Objects.equals(this.externalClientId, key.externalClientId) &&
Objects.equals(this.clientStorageProvider, key.clientStorageProvider) &&
Objects.equals(this.offline, key.offline);
}
@Override

View File

@@ -29,6 +29,7 @@ import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.util.Objects;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@@ -37,7 +38,10 @@ import java.io.Serializable;
@NamedQuery(name="deleteUserSessionsByRealm", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId"),
@NamedQuery(name="deleteUserSessionsByRealmSessionType", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId and sess.offline = :offline"),
@NamedQuery(name="deleteUserSessionsByUser", query="delete from PersistentUserSessionEntity sess where sess.userId = :userId"),
// The query "deleteExpiredUserSessions" is deprecated (since 26.5) and may be removed in the future.
@NamedQuery(name="deleteExpiredUserSessions", query="delete from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"),
@NamedQuery(name="deleteUserSessions", query="delete from PersistentUserSessionEntity sess where sess.offline = :offline AND sess.userSessionId IN (:userSessionId)"),
@NamedQuery(name="findExpiredUserSessions", query="select sess.userSessionId, sess.userId from PersistentUserSessionEntity sess where sess.realmId = :realmId AND sess.offline = :offline AND sess.lastSessionRefresh < :lastSessionRefresh"),
@NamedQuery(name="updateUserSessionLastSessionRefresh", query="update PersistentUserSessionEntity sess set lastSessionRefresh = :lastSessionRefresh where sess.realmId = :realmId" +
" AND sess.offline = :offline AND sess.userSessionId IN (:userSessionIds)"),
@NamedQuery(name="findUserSessionsCount", query="select count(sess) from PersistentUserSessionEntity sess where sess.offline = :offline"),
@@ -192,10 +196,8 @@ public class PersistentUserSessionEntity {
Key key = (Key) o;
if (this.userSessionId != null ? !this.userSessionId.equals(key.userSessionId) : key.userSessionId != null) return false;
if (this.offline != null ? !this.offline.equals(key.offline) : key.offline != null) return false;
return true;
return Objects.equals(this.userSessionId, key.userSessionId) &&
Objects.equals(this.offline, key.offline);
}
@Override

View File

@@ -36,7 +36,7 @@ public class EventOptions {
"execute_actions", "execute_action_token", "client_info", "client_register", "client_update", "client_delete", "client_initiated_account_linking", "token_exchange",
"oauth2_device_auth", "oauth2_device_verify_user_code", "oauth2_device_code_to_token", "authreqid_to_token", "permission_token", "delete_account", "pushed_authorization_request",
"user_disabled_by_permanent_lockout", "user_disabled_by_temporary_lockout", "oauth2_extension_grant", "federated_identity_override_link", "update_credential", "remove_credential",
"invite_org", "remove_totp", "update_totp", "update_password"));
"invite_org", "remove_totp", "update_totp", "update_password", "user_session_deleted"));
events.sort(String::compareToIgnoreCase);
return events;
}

View File

@@ -211,7 +211,7 @@ public class HelpCommandDistTest {
// normalize the output to prevent changes around the feature toggles or events to mark the output to differ
String output = cliResult.getOutput()
.replaceAll("((Disables|Enables) a set of one or more features. Possible values are: )[^.]{30,}", "$1<...>")
.replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.(Available|only|when|user|event|metrics|are|enabled.| )*", "$1<...>");
.replaceAll("(create a metric.\\s+Possible values are:)[^.]{30,}.[^.]*.", "$1<...>");
String osName = System.getProperty("os.name");
if(osName.toLowerCase(Locale.ROOT).contains("windows")) {

View File

@@ -114,4 +114,6 @@ public interface Details {
String CLIENT_POLICY_ERROR = "client_policy_error";
String CLIENT_POLICY_ERROR_DETAIL = "client_policy_error_detail";
String EXPIRED_DETAIL = "expired";
}

View File

@@ -18,7 +18,6 @@
package org.keycloak.events;
import java.util.Map;
import java.util.Objects;
import org.keycloak.util.EnumWithStableIndex;
/**
@@ -185,14 +184,17 @@ public enum EventType implements EnumWithStableIndex {
REMOVE_CREDENTIAL_ERROR(0x10000 + REMOVE_CREDENTIAL.getStableIndex(), true),
INVITE_ORG(60, true),
INVITE_ORG_ERROR(0x10000 + INVITE_ORG.getStableIndex(), true);
INVITE_ORG_ERROR(0x10000 + INVITE_ORG.getStableIndex(), true),
USER_SESSION_DELETED(61, false),
USER_SESSION_DELETED_ERROR(0x10000 + USER_SESSION_DELETED.getStableIndex(), false),
;
private final int stableIndex;
private final boolean saveByDefault;
private static final Map<Integer, EventType> BY_ID = EnumWithStableIndex.getReverseIndex(values());
EventType(int stableIndex, boolean saveByDefault) {
Objects.requireNonNull(stableIndex);
this.stableIndex = stableIndex;
this.saveByDefault = saveByDefault;
}
@@ -204,7 +206,6 @@ public enum EventType implements EnumWithStableIndex {
/**
* Determines whether this event is stored when the admin has not set a specific set of event types to save.
* @return
*/
public boolean isSaveByDefault() {
return saveByDefault;

View File

@@ -29,6 +29,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oidc.grants.AuthorizationCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.grants.RefreshTokenGrantTypeFactory;
@@ -68,7 +69,7 @@ public class AssertEvents implements TestRule {
public static final String DEFAULT_REDIRECT_URI = getAuthServerContextRoot() + "/auth/realms/master/app/auth";
private AbstractKeycloakTest context;
private final AbstractKeycloakTest context;
public AssertEvents(AbstractKeycloakTest ctx) {
context = ctx;
@@ -188,6 +189,44 @@ public class AssertEvents implements TestRule {
.session(sessionId);
}
public ExpectedEvent expectSessionExpired(String sessionId, String userId) {
return expect(EventType.USER_SESSION_DELETED)
.session(sessionId)
.user(userId)
.detail(Details.REASON, Details.EXPIRED_DETAIL)
.client((String) null)
.ipAddress((String) null);
}
public void assertRefreshTokenErrorAndMaybeSessionExpired(String sessionId, String userId, String clientId) {
// events can be in any order
ExpectedEvent expired = expectSessionExpired(sessionId, userId);
ExpectedEvent refresh = expect(EventType.REFRESH_TOKEN)
.session(sessionId)
.client(clientId)
.error(Errors.INVALID_TOKEN)
.user((String) null);
EventRepresentation e = poll(5);
if (e.getType().equals(EventType.USER_SESSION_DELETED.name())) {
// if we get an expiration event, we must receive the refresh token error event.
expired.assertEvent(e);
refresh.assertEvent();
return;
}
if (e.getType().equals(EventType.REFRESH_TOKEN_ERROR.name())) {
refresh.assertEvent(e);
// The session expiration event is optional.
// With volatile session send an event because Infinispan sends events on reads.
// With persistent session only sends the events during the periodic cleanup task.
e = fetchNextEvent();
if (e != null) {
expired.assertEvent(e);
}
return;
}
Assert.fail("Unexpected event type: " + e.getType());
}
public ExpectedEvent expectLogout(String sessionId) {
return expect(EventType.LOGOUT)
.detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI))
@@ -271,7 +310,7 @@ public class AssertEvents implements TestRule {
}
public class ExpectedEvent {
private EventRepresentation expected = new EventRepresentation();
private final EventRepresentation expected = new EventRepresentation();
private Matcher<String> realmId;
private Matcher<String> userId;
private Matcher<String> sessionId;
@@ -440,23 +479,14 @@ public class AssertEvents implements TestRule {
assertThat("session ID", actual.getSessionId(), is(sessionId));
if (details == null || details.isEmpty()) {
// Assert.assertNull(actual.getDetails());
} else {
Assert.assertNotNull(actual.getDetails());
for (Map.Entry<String, Matcher<? super String>> d : details.entrySet()) {
String actualValue = actual.getDetails().get(d.getKey());
assertThat("Unexpected value for " + d.getKey(), actualValue, d.getValue());
}
/*
for (String k : actual.getDetails().keySet()) {
if (!details.containsKey(k)) {
Assert.fail(k + " was not expected");
}
}
*/
return actual;
}
Assert.assertNotNull(actual.getDetails());
for (Map.Entry<String, Matcher<? super String>> d : details.entrySet()) {
String actualValue = actual.getDetails().get(d.getKey());
assertThat("Unexpected value for " + d.getKey(), actualValue, d.getValue());
}
return actual;
}

View File

@@ -82,8 +82,6 @@ import jakarta.ws.rs.NotFoundException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
@@ -128,7 +126,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
@Before
public void clientConfiguration() {
userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId();
oauth.clientId("test-app");
oauth.client("test-app");
}
@Override
@@ -174,13 +172,11 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenDisabledForClient() throws Exception {
public void offlineTokenDisabledForClient() {
// Remove offline-access scope from client
ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream().filter((ClientScopeRepresentation clientScope) -> {
return OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName());
}).findFirst().get();
ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream()
.filter((ClientScopeRepresentation clientScope) -> OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName()))
.findFirst().get();
ClientManager.realm(adminClient.realm("test")).clientId("offline-client")
.fullScopeAllowed(false)
@@ -201,7 +197,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenUserNotAllowed() throws Exception {
public void offlineTokenUserNotAllowed() {
String userId = findUserByUsername(adminClient.realm("test"), "keycloak-user@localhost").getId();
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
@@ -234,7 +230,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenBrowserFlow() throws Exception {
public void offlineTokenBrowserFlow() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -264,9 +260,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
Assert.assertNull(offlineToken.getExp());
AccessTokenContext ctx = testingClient.testing("test").getTokenContext(token.getId());
Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE);
Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR);
Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.AUTHORIZATION_CODE);
Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType());
Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType());
Assert.assertEquals(OAuth2Constants.AUTHORIZATION_CODE, ctx.getGrantType());
assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS));
@@ -283,18 +279,13 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
Assert.assertEquals(400, response.getStatusCode());
assertEquals("invalid_grant", response.getError());
events.expectRefresh(offlineToken.getId(), newRefreshToken.getSessionState())
.client("offline-client")
.user((String) null)
.error(Errors.INVALID_TOKEN)
.clearDetails()
.assertEvent();
events.assertRefreshTokenErrorAndMaybeSessionExpired(newRefreshToken.getSessionId(), loginEvent.getUserId(), "offline-client");
setTimeOffset(0);
}
@Test
public void onlineOfflineTokenBrowserFlow() throws Exception {
public void onlineOfflineTokenBrowserFlow() {
// request an online token for the client
oauth.scope(null);
oauth.client("offline-client", "secret1");
@@ -373,9 +364,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken());
Assert.assertEquals(200, response.getStatusCode());
AccessTokenContext ctx = testingClient.testing("test").getTokenContext(refreshedToken.getId());
Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE);
Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR);
Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.REFRESH_TOKEN);
Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType());
Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType());
Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN, ctx.getGrantType());
// Assert new refreshToken in the response
String newRefreshToken = response.getRefreshToken();
@@ -419,7 +410,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenDirectGrantFlow() throws Exception {
public void offlineTokenDirectGrantFlow() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
@@ -456,7 +447,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() throws Exception {
public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() {
RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true);
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
@@ -470,7 +461,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
events.expectLogin()
.client("offline-client")
.user(userId)
.session(token.getSessionState())
.session(token.getSessionId())
.detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD)
.detail(Details.TOKEN_ID, token.getId())
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
@@ -484,13 +475,13 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
Assert.assertNull(offlineToken.getExp());
String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId);
String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId);
RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2);
// Assert second refresh with same refresh token will fail
AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString);
Assert.assertEquals(400, response.getStatusCode());
events.expectRefresh(offlineToken.getId(), token.getSessionState())
events.expectRefresh(offlineToken.getId(), token.getSessionId())
.client("offline-client")
.user((String) null)
.error(Errors.INVALID_TOKEN)
@@ -500,7 +491,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
// Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token)
AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2);
Assert.assertEquals(400, response2.getStatusCode());
events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionState())
events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionId())
.client("offline-client")
.user((String) null)
.error(Errors.INVALID_TOKEN)
@@ -511,7 +502,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenServiceAccountFlow() throws Exception {
public void offlineTokenServiceAccountFlow() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
@@ -564,7 +555,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenAllowedWithCompositeRole() throws Exception {
public void offlineTokenAllowedWithCompositeRole() {
RealmResource appRealm = adminClient.realm("test");
UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost");
RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"),
@@ -591,10 +582,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
/**
* KEYCLOAK-4201
*
* @throws Exception
*/
@Test
public void offlineTokenAdminRESTAccess() throws Exception {
public void offlineTokenAdminRESTAccess() {
// Grant "view-realm" role to user
RealmResource appRealm = adminClient.realm("test");
ClientResource realmMgmt = ApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID);
@@ -632,7 +622,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
// KEYCLOAK-4525
@Test
public void offlineTokenRemoveClientWithTokens() throws Exception {
public void offlineTokenRemoveClientWithTokens() {
// Create new client
RealmResource appRealm = adminClient.realm("test");
@@ -641,7 +631,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.directAccessGrants()
.secret("secret1").build();
appRealm.clients().create(clientRep);
appRealm.clients().create(clientRep).close();
// Direct grant login requesting offline token
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
@@ -655,7 +645,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
events.expectLogin()
.client("offline-client-2")
.user(userId)
.session(token.getSessionState())
.session(token.getSessionId())
.detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD)
.detail(Details.TOKEN_ID, token.getId())
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
@@ -673,7 +663,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
for (Map<String, Object> consent : userConsents) {
if (consent.get("clientId").equals("offline-client-2")) {
clientId2 = String.valueOf(consent.get("clientId"));
offlineAdditionalGrant = String.valueOf(((LinkedHashMap) ((ArrayList) consent.get("additionalGrants")).get(0)).get("key"));
//noinspection unchecked
offlineAdditionalGrant = String.valueOf((((List<Map<String, ?>>) consent.get("additionalGrants")).get(0)).get("key"));
}
}
@@ -691,12 +682,12 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
UserResource user = ApiUtil.findUserByUsernameId(appRealm, "test-user@localhost");
List<Map<String, Object>> consents = user.getConsents();
for (Map<String, Object> consent : consents) {
assertNotEquals(consent.get("clientId"), "offline-client-2");
assertNotEquals("offline-client-2", consent.get("clientId"));
}
}
@Test
public void offlineTokenLogout() throws Exception {
public void offlineTokenLogout() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password");
@@ -713,7 +704,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void onlineOfflineTokenLogout() throws Exception {
public void onlineOfflineTokenLogout() {
oauth.client("offline-client", "secret1");
// create online session
@@ -747,7 +738,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void browserOfflineTokenLogoutFollowedByLoginSameSession() throws Exception {
public void browserOfflineTokenLogoutFollowedByLoginSameSession() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -777,7 +768,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
assertNull(offlineToken.getExp());
String offlineUserSessionId = testingClient.server().fetch((KeycloakSession session) ->
session.sessions().getOfflineUserSession(session.realms().getRealmByName("test"), offlineToken.getSessionState()).getId(), String.class);
session.sessions().getOfflineUserSession(session.realms().getRealmByName("test"), offlineToken.getSessionId()).getId(), String.class);
// logout offline session
LogoutResponse logoutResponse = oauth.doLogout(offlineTokenString);
@@ -804,7 +795,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
codeId = loginEvent.getDetails().get(Details.CODE_ID);
events.expectCodeToToken(codeId, offlineToken2.getSessionState())
events.expectCodeToToken(codeId, offlineToken2.getSessionId())
.client("offline-client")
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.assertEvent();
@@ -813,7 +804,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
Assert.assertNull(offlineToken.getExp());
// Assert session changed
assertNotEquals(offlineToken.getSessionState(), offlineToken2.getSessionState());
assertNotEquals(offlineToken.getSessionId(), offlineToken2.getSessionId());
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
@@ -843,7 +834,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenBrowserFlowMaxLifespanExpired() throws Exception {
public void offlineTokenBrowserFlowMaxLifespanExpired() {
// expect that offline session expired by max lifespan
final int MAX_LIFESPAN = 3600;
final int IDLE_LIFESPAN = 6000;
@@ -851,7 +842,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineTokenBrowserFlowIdleTimeExpired() throws Exception {
public void offlineTokenBrowserFlowIdleTimeExpired() {
// expect that offline session expired by idle time
final int MAX_LIFESPAN = 3000;
final int IDLE_LIFESPAN = 600;
@@ -868,8 +859,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
getTestingClient().testing().setTestingInfinispanTimeService();
int prev[] = null;
try (RealmAttributeUpdater rau = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) {
int[] prev = null;
try (RealmAttributeUpdater ignored = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) {
prev = changeOfflineSessionSettings(true, MAX_LIFESPAN, IDLE_LIFESPAN, 0, 0);
// Step 1 - online login with "tets-app"
@@ -917,8 +908,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
}
private RefreshToken assertOfflineToken(AccessTokenResponse tokenResponse) {
return assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE);
private void assertOfflineToken(AccessTokenResponse tokenResponse) {
assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE);
}
// Asserts that refresh token in the tokenResponse is of the given type. Return parsed token
@@ -977,7 +968,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}, Integer.class);
}
private void removeClientSessionStartedAtNote(final String userSessionId, final String clientId, final String clientSessionId) {
private void removeClientSessionStartedAtNote(final String userSessionId, final String clientId) {
testingClient.server().run(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
session.getContext().setRealm(realmModel);
@@ -1027,9 +1018,9 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
setTimeOffset(offsetHalf);
tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString);
AccessToken refreshedToken = oauth.verifyToken(tokenResponse.getAccessToken());
oauth.verifyToken(tokenResponse.getAccessToken());
offlineTokenString = tokenResponse.getRefreshToken();
offlineToken = oauth.parseRefreshToken(offlineTokenString);
oauth.parseRefreshToken(offlineTokenString);
Assert.assertEquals(200, tokenResponse.getStatusCode());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
@@ -1064,7 +1055,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
oauth.client("offline-client", "secret1");
AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
JWSHeader header = null;
JWSHeader header;
String idToken = tokenResponse.getIdToken();
String accessToken = tokenResponse.getAccessToken();
String refreshToken = tokenResponse.getRefreshToken();
@@ -1094,7 +1085,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
events.expectClientLogin()
.client("offline-client")
.user(serviceAccountUserId)
.session(token.getSessionState())
.session(token.getSessionId())
.detail(Details.TOKEN_ID, token.getId())
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
@@ -1104,7 +1095,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
Assert.assertNull(offlineToken.getExp());
testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId);
testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId);
// Now retrieve another offline token and decode that previous offline token is still valid
tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
@@ -1116,7 +1107,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
events.expectClientLogin()
.client("offline-client")
.user(serviceAccountUserId)
.session(token2.getSessionState())
.session(token2.getSessionId())
.detail(Details.TOKEN_ID, token2.getId())
.detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId())
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
@@ -1124,9 +1115,8 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.assertEvent();
// Refresh with both offline tokens is fine
testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId);
testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionState(), serviceAccountUserId);
testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId);
testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId);
}
private void offlineTokenRequestWithScopeParameter(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception {
@@ -1136,7 +1126,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
oauth.client("offline-client", "secret1");
AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
JWSHeader header = null;
JWSHeader header;
String idToken = tokenResponse.getIdToken();
String accessToken = tokenResponse.getAccessToken();
String refreshToken = tokenResponse.getRefreshToken();
@@ -1166,7 +1156,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
events.expectClientLogin()
.client("offline-client")
.user(serviceAccountUserId)
.session(token.getSessionState())
.session(token.getSessionId())
.detail(Details.TOKEN_ID, token.getId())
.detail(Details.REFRESH_TOKEN_ID, offlineToken.getId())
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
@@ -1178,7 +1168,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void refreshTokenUserClientMaxLifespanSmallerThanSession() throws Exception {
public void refreshTokenUserClientMaxLifespanSmallerThanSession() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -1225,7 +1215,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void refreshTokenUserClientMaxLifespanGreaterThanSession() throws Exception {
public void refreshTokenUserClientMaxLifespanGreaterThanSession() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -1272,7 +1262,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() throws Exception {
public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -1306,7 +1296,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
events.expect(EventType.REFRESH_TOKEN).session(sessionId).client("offline-client").error(Errors.INVALID_TOKEN).user((String) null).assertEvent();
events.assertRefreshTokenErrorAndMaybeSessionExpired(sessionId, loginEvent.getUserId(), "offline-client");
assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
@@ -1317,7 +1307,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() throws Exception {
public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(offlineClientAppUri);
@@ -1387,7 +1377,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
JsonNode jsonNode = oauth.doIntrospectionAccessTokenRequest(tokenResponse.getAccessToken()).asJsonNode();
Assert.assertEquals(true, jsonNode.get("active").asBoolean());
assertTrue(jsonNode.get("active").asBoolean());
Assert.assertEquals("test-user@localhost", jsonNode.get("email").asText());
assertThat(jsonNode.get("exp").asInt() - getCurrentTime(),
allOf(greaterThanOrEqualTo(59), lessThanOrEqualTo(60)));
@@ -1399,7 +1389,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void testClientOfflineSessionMaxLifespan() throws Exception {
public void testClientOfflineSessionMaxLifespan() {
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRepresentation = client.toRepresentation();
@@ -1451,7 +1441,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void testClientOfflineSessionIdleTimeout() throws Exception {
public void testClientOfflineSessionIdleTimeout() {
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRepresentation = client.toRepresentation();
@@ -1529,7 +1519,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
}
@Test
public void offlineRefreshWhenNoStartedAtClientNote() throws Exception {
public void offlineRefreshWhenNoStartedAtClientNote() {
int prevOfflineSession[] = null;
try {
prevOfflineSession = changeOfflineSessionSettings(true, 3600, 3600, 0, 0);
@@ -1548,8 +1538,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.assertEvent();
// remove the started notes that can be missed in previous versions
String clientSessionId = getOfflineClientSessionUuid(loginEvent.getSessionId(), loginEvent.getClientId());
removeClientSessionStartedAtNote(loginEvent.getSessionId(), loginEvent.getClientId(), clientSessionId);
removeClientSessionStartedAtNote(loginEvent.getSessionId(), loginEvent.getClientId());
// check refresh is successful
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
@@ -1614,7 +1603,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.assertEvent();
// remove offline scope from the client and perform a second refresh
try (ClientAttributeUpdater updater = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client")
try (ClientAttributeUpdater ignored = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client")
.removeOptionalClientScope("offline_access").update()) {
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken());

View File

@@ -38,9 +38,9 @@ import org.keycloak.common.enums.SslRequired;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.cookie.CookieType;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.EventType;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticatedClientSessionModel;
@@ -76,8 +76,8 @@ import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.BrowserTabUtil;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.RealmManager;
import org.keycloak.testsuite.util.TokenSignatureUtil;
@@ -85,7 +85,7 @@ import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.UserManager;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.util.BrowserTabUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.BasicAuthHelper;
import org.openqa.selenium.Cookie;
@@ -101,26 +101,28 @@ import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.lessThan;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME;
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_IDLE_TIMEOUT;
import static org.keycloak.protocol.oidc.OIDCConfigAttributes.CLIENT_SESSION_MAX_LIFESPAN;
import static org.keycloak.testsuite.Assert.assertExpiration;
import static org.keycloak.testsuite.AbstractAdminTest.loadJson;
import static org.keycloak.testsuite.Assert.assertExpiration;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername;
import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.getHttpAuthServerContextRoot;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED;
import static org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.getHttpAuthServerContextRoot;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -176,16 +178,13 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
URI uri = OIDCLoginProtocolService.tokenUrl(builder).build("test");
WebTarget target = client.target(uri);
org.keycloak.representations.AccessTokenResponse tokenResponse = null;
{
String header = BasicAuthHelper.createHeader("test-app", "password");
Form form = new Form();
Response response = target.request()
.header(HttpHeaders.AUTHORIZATION, header)
.post(Entity.form(form));
assertEquals(400, response.getStatus());
response.close();
}
String header = BasicAuthHelper.createHeader("test-app", "password");
Form form = new Form();
Response response = target.request()
.header(HttpHeaders.AUTHORIZATION, header)
.post(Entity.form(form));
assertEquals(400, response.getStatus());
response.close();
events.clear();
}
@@ -256,7 +255,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
long actual = refreshToken.getExp() - getCurrentTime();
assertThat(actual, allOf(greaterThanOrEqualTo(1799L - ALLOWED_CLOCK_SKEW), lessThanOrEqualTo(1800L + ALLOWED_CLOCK_SKEW)));
assertEquals(sessionId, refreshToken.getSessionState());
assertEquals(sessionId, refreshToken.getSessionId());
assertNull(refreshToken.getNonce());
AccessTokenResponse response = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
@@ -265,8 +264,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertEquals(200, response.getStatusCode());
assertEquals(sessionId, refreshedToken.getSessionState());
assertEquals(sessionId, refreshedRefreshToken.getSessionState());
assertEquals(sessionId, refreshedToken.getSessionId());
assertEquals(sessionId, refreshedRefreshToken.getSessionId());
assertThat(response.getExpiresIn(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300)));
assertThat(refreshedToken.getExp() - getCurrentTime(), allOf(greaterThanOrEqualTo(250L - ALLOWED_CLOCK_SKEW), lessThanOrEqualTo(300L + ALLOWED_CLOCK_SKEW)));
@@ -303,7 +302,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertNotNull(response.getRefreshToken());
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(sessionId, refreshToken.getSessionState());
assertEquals(sessionId, refreshToken.getSessionId());
assertNull(refreshToken.getNonce());
}
@@ -360,32 +359,30 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
// Test when neither client nor user session is in the cache
testingClient.server().run(session -> {
session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).clear();
session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).clear();
session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).clear();
session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).clear();
});
response = oauth.doRefreshTokenRequest(refreshTokenString);
Assert.assertEquals(200, response.getStatusCode());
testingClient.server().run(session -> {
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).size(),
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(),
greaterThan(0));
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).size(),
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).size(),
greaterThan(0));
});
// Test is only the client session is missing
testingClient.server().run(session -> {
session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).clear();
});
testingClient.server().run(session -> session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).clear());
response = oauth.doRefreshTokenRequest(refreshTokenString);
Assert.assertEquals(200, response.getStatusCode());
testingClient.server().run(session -> {
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME).size(),
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(USER_SESSION_CACHE_NAME).size(),
greaterThan(0));
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME).size(),
assertThat(session.getProvider(InfinispanConnectionProvider.class).getCache(CLIENT_SESSION_CACHE_NAME).size(),
greaterThan(0));
});
@@ -428,20 +425,20 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
.clientId("public-client")
.redirectUris("*")
.publicClient()
.build());
.build()).close();
realmResource.users()
.create(UserBuilder.create().username("alice")
.firstName("alice")
.lastName("alice")
.email("alice@keycloak.org")
.password("alice").addRoles("offline_access").build());
.password("alice").addRoles("offline_access").build()).close();
realmResource.users()
.create(UserBuilder.create().username("bob")
.firstName("bob")
.lastName("bob")
.email("bob@keycloak.org")
.password("bob").addRoles("offline_access").build());
.password("bob").addRoles("offline_access").build()).close();
oauth.realm(realmName);
oauth.client("public-client");
@@ -493,12 +490,12 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
.clientId("public-client")
.redirectUris("*")
.publicClient()
.build());
.build()).close();
realmResource.users()
.create(UserBuilder.create().username("alice").password("alice").addRoles("offline_access").build());
.create(UserBuilder.create().username("alice").password("alice").addRoles("offline_access").build()).close();
realmResource.users()
.create(UserBuilder.create().username("bob").password("bob").addRoles("offline_access").build());
.create(UserBuilder.create().username("bob").password("bob").addRoles("offline_access").build()).close();
oauth.realm(realmName);
oauth.client("public-client");
@@ -509,7 +506,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
oauth.fillLoginForm("alice", "alice");
String aliceCode = oauth.parseLoginResponse().getCode();
oauth.parseLoginResponse().getCode();
// WebClient webClient = DroneHtmlUnitDriver.class.cast(driver).getWebClient();
// webClient.getCookieManager().clearCookies();
driver.manage().deleteAllCookies();
@@ -585,7 +582,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
try {
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
oauth.parseLoginResponse().getCode();
String optionalScope = "phone address";
oauth.scope(optionalScope);
@@ -999,7 +996,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response4 = oauth.doAccessTokenRequest(code);
RefreshToken refreshToken4 = oauth.parseRefreshToken(response4.getRefreshToken());
oauth.parseRefreshToken(response4.getRefreshToken());
events.expectCodeToToken(codeId, sessionId).assertEvent();
// Client sessions should be available again now after re-authentication
@@ -1121,7 +1118,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertFalse(loginPage.isCurrent());
AccessTokenResponse tokenResponse2 = null;
AccessTokenResponse tokenResponse2;
String code = oauth.parseLoginResponse().getCode();
tokenResponse2 = oauth.doAccessTokenRequest(code);
@@ -1155,7 +1152,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertFalse(loginPage.isCurrent());
AccessTokenResponse tokenResponse2 = null;
AccessTokenResponse tokenResponse2;
String code = oauth.parseLoginResponse().getCode();
tokenResponse2 = oauth.doAccessTokenRequest(code);
@@ -1188,7 +1185,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertFalse(loginPage.isCurrent());
AccessTokenResponse tokenResponse2 = null;
AccessTokenResponse tokenResponse2;
String code = oauth.parseLoginResponse().getCode();
tokenResponse2 = oauth.doAccessTokenRequest(code);
@@ -1231,8 +1228,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
AccessToken refreshedToken = oauth.verifyToken(tokenResponse.getAccessToken());
RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(tokenResponse.getRefreshToken());
oauth.verifyToken(tokenResponse.getAccessToken());
oauth.parseRefreshToken(tokenResponse.getRefreshToken());
assertEquals(200, tokenResponse.getStatusCode());
@@ -1281,7 +1278,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
public void testUserSessionRefreshAndIdleRememberMe() throws Exception {
RealmResource testRealm = adminClient.realm("test");
try (Closeable realmUpdater = new RealmAttributeUpdater(testRealm)
try (Closeable ignored = new RealmAttributeUpdater(testRealm)
.updateWith(r -> {
r.setRememberMe(true);
r.setSsoSessionIdleTimeoutRememberMe(500);
@@ -1357,7 +1354,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
public void refreshTokenUserSessionMaxLifespan() throws Exception {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(3600);
r.setSsoSessionIdleTimeout(7200);
@@ -1384,7 +1381,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
events.expectRefresh(refreshId, sessionId).assertEvent();
setTimeOffset(3700);
oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId();
oauth.parseRefreshToken(tokenResponse.getRefreshToken());
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(400, tokenResponse.getStatusCode());
@@ -1403,7 +1400,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
public void refreshTokenUserClientMaxLifespanSmallerThanSession() throws Exception {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(3600);
r.setSsoSessionIdleTimeout(7200);
@@ -1469,7 +1466,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
public void refreshTokenUserClientMaxLifespanGreaterThanSession() throws Exception {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(3600);
r.setSsoSessionIdleTimeout(7200);
@@ -1516,7 +1513,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(7200);
r.setSsoSessionIdleTimeout(7200);
@@ -1545,7 +1542,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
events.expect(EventType.REFRESH_TOKEN).error(Errors.INVALID_TOKEN).session(sessionId).user((String) null).assertEvent();
events.assertRefreshTokenErrorAndMaybeSessionExpired(sessionId, loginEvent.getUserId(), loginEvent.getClientId());
assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
getTestingClient().testing().revertTestingInfinispanTimeService();
@@ -1559,7 +1556,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(7200);
r.setSsoSessionIdleTimeout(7200);
@@ -1624,7 +1621,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RealmResource realmResource = adminClient.realm("test");
getTestingClient().testing().setTestingInfinispanTimeService();
try (Closeable realmUpdater = new RealmAttributeUpdater(realmResource)
try (Closeable ignored = new RealmAttributeUpdater(realmResource)
.updateWith(r -> {
r.setSsoSessionMaxLifespan(7200);
r.setSsoSessionIdleTimeout(7200);
@@ -1684,7 +1681,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
RealmResource testRealm = adminClient.realm("test");
try (Closeable realmUpdater = new RealmAttributeUpdater(testRealm)
try (Closeable ignored = new RealmAttributeUpdater(testRealm)
.updateWith(r -> {
r.setRememberMe(true);
r.setSsoSessionMaxLifespanRememberMe(100);
@@ -1855,8 +1852,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
testingClient.server("test").run(session -> {
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
if (connections != null) {
Cache<String, SessionEntityWrapper<UserSessionEntity>> sessionCache = connections.getCache(InfinispanConnectionProvider.USER_SESSION_CACHE_NAME);
Cache<UUID, SessionEntityWrapper<AuthenticatedClientSessionEntity>> clientSessionCache = connections.getCache(InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME);
Cache<String, SessionEntityWrapper<UserSessionEntity>> sessionCache = connections.getCache(USER_SESSION_CACHE_NAME);
Cache<UUID, SessionEntityWrapper<AuthenticatedClientSessionEntity>> clientSessionCache = connections.getCache(CLIENT_SESSION_CACHE_NAME);
if (sessionCache != null) {
sessionCache.clear();
}
@@ -1891,8 +1888,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
@Test
public void testCheckSsl() {
Client client = AdminClientUtil.createResteasyClient();
try {
try (Client client = AdminClientUtil.createResteasyClient()) {
UriBuilder builder = UriBuilder.fromUri(AUTH_SERVER_ROOT);
URI grantUri = OIDCLoginProtocolService.tokenUrl(builder).build("test");
WebTarget grantTarget = client.target(grantUri);
@@ -1900,7 +1896,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
URI uri = OIDCLoginProtocolService.tokenUrl(builder).build("test");
WebTarget refreshTarget = client.target(uri);
String refreshToken = null;
String refreshToken;
{
Response response = executeGrantAccessTokenRequest(grantTarget);
assertEquals(200, response.getStatus());
@@ -1932,15 +1928,11 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
}
}
{
Response response = executeRefreshToken(refreshTarget, refreshToken);
assertEquals(200, response.getStatus());
org.keycloak.representations.AccessTokenResponse tokenResponse = response.readEntity(org.keycloak.representations.AccessTokenResponse.class);
refreshToken = tokenResponse.getRefreshToken();
response.close();
}
Response response = executeRefreshToken(refreshTarget, refreshToken);
assertEquals(200, response.getStatus());
response.readEntity(org.keycloak.representations.AccessTokenResponse.class);
response.close();
} finally {
client.close();
events.clear();
}
@@ -1993,7 +1985,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
events.expectCodeToToken(codeId, sessionId).user(userId).assertEvent();
adminClient.realm("test").users().delete(userId);
adminClient.realm("test").users().delete(userId).close();
response = oauth.doRefreshTokenRequest(refreshTokenString);
assertEquals(400, response.getStatusCode());
@@ -2102,6 +2094,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
@Test // KEYCLOAK-17323
public void testRefreshTokenWhenClientSessionTimeoutPassedButRealmDidNot() {
//noinspection resource
getCleanup()
.addCleanup(new RealmAttributeUpdater(adminClient.realm("test"))
.setSsoSessionIdleTimeout(2592000) // 30 Days
@@ -2219,7 +2212,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
.post(Entity.form(form));
}
private void conductTokenRefreshRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception {
private void conductTokenRefreshRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception {
try {
// Realm setting is used for ID Token signature algorithm
TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, expectedIdTokenAlg);
@@ -2268,7 +2261,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertEquals("Bearer", tokenResponse.getTokenType());
assertEquals(sessionId, refreshToken.getSessionState());
assertEquals(sessionId, refreshToken.getSessionId());
AccessTokenResponse response = oauth.doRefreshTokenRequest(refreshTokenString);
if (response.getError() != null || response.getErrorDescription() != null) {
@@ -2280,8 +2273,8 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
assertEquals(200, response.getStatusCode());
assertEquals(sessionId, refreshedToken.getSessionState());
assertEquals(sessionId, refreshedRefreshToken.getSessionState());
assertEquals(sessionId, refreshedToken.getSessionId());
assertEquals(sessionId, refreshedRefreshToken.getSessionId());
Assert.assertNotEquals(token.getId(), refreshedToken.getId());
Assert.assertNotEquals(refreshToken.getId(), refreshedRefreshToken.getId());

View File

@@ -128,6 +128,12 @@
<groupId>org.infinispan</groupId>
<artifactId>infinispan-remote-query-server</artifactId>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.model.session;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.awaitility.Awaitility;
import org.infinispan.Cache;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Event;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.session.UserSessionPersisterProvider;
import org.keycloak.testsuite.model.HotRodServerRule;
import org.keycloak.testsuite.model.KeycloakModelTest;
import org.keycloak.testsuite.model.RequireProvider;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.OFFLINE_USER_SESSION_CACHE_NAME;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.USER_SESSION_CACHE_NAME;
import static org.keycloak.models.utils.SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS;
import static org.keycloak.testsuite.model.session.UserSessionPersisterProviderTest.createClients;
import static org.keycloak.testsuite.model.session.UserSessionPersisterProviderTest.createSessions;
@RequireProvider(UserSessionPersisterProvider.class)
@RequireProvider(UserSessionProvider.class)
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(EventStoreProvider.class)
public class UserSessionExpirationTest extends KeycloakModelTest {
private static final int IDLE_TIMEOUT = 1800;
private static final int LIFESPAN_TIMEOUT = 36000;
private String realmId;
@Override
public void createEnvironment(KeycloakSession s) {
RealmModel realm = createRealm(s, "test");
realm.setEventsEnabled(true);
realm.setEnabledEventTypes(Set.of(EventType.USER_SESSION_DELETED.name()));
s.getContext().setRealm(realm);
realm.setDefaultRole(s.roles().addRealmRole(realm, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + realm.getName()));
realm.setSsoSessionIdleTimeout(IDLE_TIMEOUT);
realm.setSsoSessionMaxLifespan(LIFESPAN_TIMEOUT);
this.realmId = realm.getId();
s.users().addUser(realm, "user1").setEmail("user1@localhost");
s.users().addUser(realm, "user2").setEmail("user2@localhost");
createClients(s, realm);
}
@Override
public void cleanEnvironment(KeycloakSession s) {
RealmModel realm = s.realms().getRealm(realmId);
s.getContext().setRealm(realm);
s.sessions().removeUserSessions(realm);
UserModel user1 = s.users().getUserByUsername(realm, "user1");
UserModel user2 = s.users().getUserByUsername(realm, "user2");
UserManager um = new UserManager(s);
if (user1 != null) {
um.removeUser(realm, user1);
}
if (user2 != null) {
um.removeUser(realm, user2);
}
s.realms().removeRealm(realmId);
}
@Test
public void testExpirationEvents() {
UserSessionModel[] userSessions = inComittedTransaction(session -> {
return createSessions(session, realmId);
});
Map<String, String> sessionIdAndUsers = Arrays.stream(userSessions)
.collect(Collectors.toUnmodifiableMap(UserSessionModel::getId, s -> s.getUser().getId()));
inComittedTransaction(session -> {
// Time offset is automatically cleaned up in KeycloakModelTest.cleanEnvironment()
Time.setOffset(IDLE_TIMEOUT + PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 10);
RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
session.getProvider(UserSessionPersisterProvider.class).removeExpired(realm);
var hotRodServer = getParameters(HotRodServerRule.class).findFirst();
if (hotRodServer.isEmpty()) {
InfinispanConnectionProvider provider = session.getProvider(InfinispanConnectionProvider.class);
processExpiration(provider.getCache(USER_SESSION_CACHE_NAME));
processExpiration(provider.getCache(OFFLINE_USER_SESSION_CACHE_NAME));
} else {
hotRodServer.get().streamCacheManagers()
.forEach(cacheManager -> {
processExpiration(cacheManager.getCache(USER_SESSION_CACHE_NAME));
processExpiration(cacheManager.getCache(OFFLINE_USER_SESSION_CACHE_NAME));
});
}
// Infinispan events are async, let's ensure it is stored in the database before proceed.
Awaitility.await().until(() -> eventsCount(session) == sessionIdAndUsers.size());
});
inComittedTransaction(session -> {
RealmModel realm = session.realms().getRealm(realmId);
session.getContext().setRealm(realm);
// user session id -> user id
Map<String, String> eventsData = events(session);
Assert.assertEquals(sessionIdAndUsers, eventsData);
});
}
private static void processExpiration(Cache<?, ?> cache) {
cache.getAdvancedCache().getExpirationManager().processExpiration();
}
private static long eventsCount(KeycloakSession session) {
EventStoreProvider provider = session.getProvider(EventStoreProvider.class);
return provider.createQuery()
.type(EventType.USER_SESSION_DELETED)
.getResultStream()
.filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON)))
.count();
}
private static Map<String, String> events(KeycloakSession session) {
EventStoreProvider provider = session.getProvider(EventStoreProvider.class);
return provider.createQuery()
.type(EventType.USER_SESSION_DELETED)
.getResultStream()
.filter(event -> Details.EXPIRED_DETAIL.equals(event.getDetails().get(Details.REASON)))
.collect(Collectors.toUnmodifiableMap(Event::getSessionId, Event::getUserId));
}
}

View File

@@ -557,13 +557,8 @@ public class UserSessionPersisterProviderTest extends KeycloakModelTest {
createClientSession(session, realmId, realm.getClientByClientId("test-app"), userSession, "http://redirect", "state");
userSessionsInner.add(userSession.getId());
persistUserSession(session, userSession, true);
}
for (String userSessionId : userSessionsInner) {
UserSessionModel userSession2 = session.sessions().getUserSession(realm, userSessionId);
persistUserSession(session, userSession2, true);
}
return null;
});