Add ability to delete offline sessions via account console

Closes #15502

Signed-off-by: Robin Meese <39960884+robson90@users.noreply.github.com>
Signed-off-by: Alexander Schwartz <alexander.schwartz@gmx.net>
Co-authored-by: Alexander Schwartz <alexander.schwartz@gmx.net>
This commit is contained in:
Robin Meese 2026-01-05 08:26:47 +01:00 committed by GitHub
parent 4349f8ee6a
commit 0d0d468f27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 159 additions and 25 deletions

View File

@ -59,6 +59,11 @@ Additionally, both `IdentityProviderModel` and `IdentityProviderRepresentation`
configuration like `isHideOnLogin` to be null in order to not include these in Identity Provider types that do
not need these configurations.
=== Account UI shows and manages offline sessions
The Account UI and its REST API now shows offline sessions of a user together with regular sessions in the *Device Activity* tab.
A user can log out of those sessions like any other session, including the option *Sign out all devices*.
=== Realm default locale attempted for translation fallbacks
When localization for a realm is enabled and a translation for a message key is unavailable for the language the user selected, {project_name} now attempts to find a matching message key with the realm's default locale before defaulting to English.

View File

@ -20,6 +20,8 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -41,6 +43,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.SessionExpirationUtils;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.DeviceRepresentation;
import org.keycloak.representations.account.SessionRepresentation;
@ -91,7 +94,15 @@ public class SessionResource {
@NoCache
public Collection<DeviceRepresentation> devices() {
Map<String, DeviceRepresentation> reps = new HashMap<>();
session.sessions().getUserSessionsStream(realm, user).forEach(s -> {
// While we avoid it, there can be both an online and an offline session with the same ID.
// The user wouldn't know the difference between online and offline sessions, so we don't differentiate between them
// in the UI.
Stream.concat(
session.sessions().getUserSessionsStream(realm, user),
session.sessions().getOfflineUserSessionsStream(realm, user))
.forEach(s -> {
DeviceRepresentation device = getAttachedDevice(s);
DeviceRepresentation rep = reps
.computeIfAbsent(device.getOs() + device.getOsVersion(), key -> {
@ -131,16 +142,19 @@ public class SessionResource {
@NoCache
public Response logout(@QueryParam("current") boolean removeCurrent) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
session.sessions().getUserSessionsStream(realm, user).filter(s -> removeCurrent || !isCurrentSession(s))
.collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
.forEach(s -> {
AuthenticationManager.backchannelLogout(session, s, true);
event.clone()
.event(EventType.LOGOUT)
.user(user)
.session(s)
.success();
});
Stream.concat(
session.sessions().getUserSessionsStream(realm, user),
session.sessions().getOfflineUserSessionsStream(realm, user))
.filter(s -> removeCurrent || !isCurrentSession(s))
.collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
.forEach(s -> {
AuthenticationManager.backchannelLogout(session, s, true);
event.clone()
.event(EventType.LOGOUT)
.user(user)
.session(s)
.success();
});
return Response.noContent().build();
}
@ -156,14 +170,21 @@ public class SessionResource {
@NoCache
public Response logout(@PathParam("id") String id) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
UserSessionModel userSession = session.sessions().getUserSession(realm, id);
if (userSession != null && userSession.getUser().equals(user)) {
AuthenticationManager.backchannelLogout(session, userSession, true);
event.event(EventType.LOGOUT)
.user(user)
.session(id)
.success();
}
// While we avoid it, there can be both an online and an offline session with the same ID.
// As those have been created from the same device, it is OK to log out both of them.
Stream.concat(
Stream.ofNullable(session.sessions().getUserSession(realm, id)),
Stream.ofNullable(session.sessions().getOfflineUserSession(realm, id))).
filter(userSession -> userSession.getUser().equals(user))
.forEach(userSession -> {
AuthenticationManager.backchannelLogout(session, userSession, true);
event.event(EventType.LOGOUT)
.user(user)
.session(id)
.success();
});
return Response.noContent().build();
}
@ -174,10 +195,20 @@ public class SessionResource {
sessionRep.setIpAddress(s.getIpAddress());
sessionRep.setStarted(s.getStarted());
sessionRep.setLastAccess(s.getLastSessionRefresh());
int maxLifespan = s.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0
? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan();
int expires = s.getStarted() + maxLifespan;
sessionRep.setExpires(expires);
long expires = SessionExpirationUtils.calculateUserSessionMaxLifespanTimestamp(
s.isOffline(),
s.isRememberMe(),
TimeUnit.SECONDS.toMillis(s.getStarted()),
realm);
if (expires == -1) {
// Offline sessions can have no expiry time. If that is the case, use the idle timestamp instead
expires = SessionExpirationUtils.calculateUserSessionIdleTimestamp(
s.isOffline(),
s.isRememberMe(),
TimeUnit.SECONDS.toMillis(s.getStarted()),
realm);
}
sessionRep.setExpires((int) TimeUnit.MILLISECONDS.toSeconds(expires));
sessionRep.setBrowser(device.getBrowser());
if (isCurrentSession(s)) {
@ -211,7 +242,7 @@ public class SessionResource {
private boolean isCurrentSession(UserSessionModel session) {
if (auth.getSession() == null) return false;
return session.getId().equals(auth.getSession().getId());
return session.getId().equals(auth.getSession().getId()) && Objects.equals(session.isOffline(), auth.getSession().isOffline());
}
private SessionRepresentation toRepresentation(UserSessionModel s) {

View File

@ -87,7 +87,7 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT
testRealm.getUsers().add(UserBuilder.create().username("view-applications-access").addRoles("user", "offline_access").role("account", "view-applications").role("account", "manage-consent").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("view-consent-access").role("account", "view-consent").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("manage-consent-access").role("account", "manage-consent").role("account", "view-profile").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("manage-account-access").role("account", "view-profile").role("account", "manage-account").password("password").build());
testRealm.getUsers().add(UserBuilder.create().username("manage-account-access").role("account", "view-profile").role("account", "manage-account").addRoles("user", "offline_access").password("password").build());
org.keycloak.representations.idm.ClientRepresentation inUseApp = ClientBuilder.create().clientId("in-use-client")
.id(KeycloakModelUtils.generateId())

View File

@ -17,6 +17,7 @@
package org.keycloak.testsuite.account;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@ -61,6 +62,7 @@ import org.keycloak.representations.AccessToken;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.DeviceRepresentation;
import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
@ -1243,6 +1245,102 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
events.assertEmpty();
}
@Test
public void testListingAllSignedInDevicesEvenOfflineSessionsThenTerminatingAllSessions() throws IOException, JWSInputException {
String username = "manage-account-access";
String password = "password";
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
// first direct access grant login
String firstToken = new TokenUtil(username, password).getToken();
events.expect(EventType.LOGIN)
.client("direct-grant")
.user(user.toRepresentation().getId())
.session(new JWSInput(firstToken).readJsonContent(AccessToken.class).getSessionId())
.detail(Details.SCOPE, "openid profile email")
.assertEvent();
// second direct access grant login
String secondToken = new TokenUtil(username, password).getToken();
events.expect(EventType.LOGIN)
.client("direct-grant")
.user(user.toRepresentation().getId())
.session(new JWSInput(secondToken).readJsonContent(AccessToken.class).getSessionId())
.detail(Details.SCOPE, "openid profile email")
.assertEvent();
// Login with scope 'offline_access'
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse offlineTokenResponse = oauth.doPasswordGrantRequest(username, password);
assertNull(offlineTokenResponse.getErrorDescription());
events.expect(EventType.LOGIN)
.client("offline-client")
.user(user.toRepresentation().getId())
.session(offlineTokenResponse.getSessionState())
.detail(Details.SCOPE, "openid email profile offline_access")
.assertEvent();
// Get all logged in 'devices'
Collection<DeviceRepresentation> devices = SimpleHttpDefault
.doGet(getAccountUrl("sessions/devices"), httpClient)
.header("Accept", "application/json")
.auth(firstToken)
.asJson(new TypeReference<Collection<DeviceRepresentation>>() {
});
assertFalse(devices.isEmpty());
List<SessionRepresentation> allSessions = devices.stream().flatMap(device -> device.getSessions().stream()).toList();
assertEquals(3, allSessions.size());
// User deletes all of his sessions
int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=true"), httpClient)
.acceptJson().auth(firstToken).asStatus();
assertEquals(204, status);
allSessions.forEach(session -> {
events.expectAccount(EventType.LOGOUT)
.user(user.toRepresentation().getId())
.session(session.getId())
.assertEvent();
});
}
@Test
public void testDeletionOfOfflineSessionWillFireLogoutEvent() throws IOException, JWSInputException {
String username = "manage-account-access";
String password = "password";
String firstToken = new TokenUtil(username, password).getToken();
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse offlineTokenResponse = oauth.doPasswordGrantRequest(username, password);
assertNull(offlineTokenResponse.getErrorDescription());
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
Collection<DeviceRepresentation> devices = SimpleHttpDefault
.doGet(getAccountUrl("sessions/devices"), httpClient)
.header("Accept", "application/json")
.auth(firstToken)
.asJson(new TypeReference<Collection<DeviceRepresentation>>() {
});
assertEquals(2, devices.stream().flatMap(device -> device.getSessions().stream()).toList().size());
// skip direct access login and offline_access scoped login
events.poll();
events.poll();
int status = SimpleHttpDefault.doDelete(getAccountUrl(String.format("sessions/%s", offlineTokenResponse.getSessionState())), httpClient)
.acceptJson().auth(firstToken).asStatus();
assertEquals(204, status);
events.expectAccount(EventType.LOGOUT)
.user(user.toRepresentation().getId())
.session(offlineTokenResponse.getSessionState())
.assertEvent();
events.assertEmpty();
}
@Test
public void listApplications() throws Exception {
oauth.client("in-use-client", "secret1");