From 0d0d468f2740eda31c58371cb5b41902fe31b2df Mon Sep 17 00:00:00 2001 From: Robin Meese <39960884+robson90@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:26:47 +0100 Subject: [PATCH] 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 Co-authored-by: Alexander Schwartz --- .../topics/changes/changes-26_5_0.adoc | 5 + .../resources/account/SessionResource.java | 79 ++++++++++----- .../account/AbstractRestServiceTest.java | 2 +- .../account/AccountRestServiceTest.java | 98 +++++++++++++++++++ 4 files changed, 159 insertions(+), 25 deletions(-) diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index c6ec225f978..01c49365f6b 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -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. diff --git a/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java index 472b05bf086..c075510a712 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java @@ -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 devices() { Map 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) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java index 16ccc5b395d..2d9350b1c45 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AbstractRestServiceTest.java @@ -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()) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index 25e8ebbd307..62bad5159b9 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -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 devices = SimpleHttpDefault + .doGet(getAccountUrl("sessions/devices"), httpClient) + .header("Accept", "application/json") + .auth(firstToken) + .asJson(new TypeReference>() { + }); + assertFalse(devices.isEmpty()); + List 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 devices = SimpleHttpDefault + .doGet(getAccountUrl("sessions/devices"), httpClient) + .header("Accept", "application/json") + .auth(firstToken) + .asJson(new TypeReference>() { + }); + 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");