From 095757275195cc8a8135dfa6c97c16fcf79fb35d Mon Sep 17 00:00:00 2001 From: Robin Meese <39960884+robson90@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:25:45 +0100 Subject: [PATCH] Add logout event to SessionResource Closes #44842 Signed-off-by: Robin Meese <39960884+robson90@users.noreply.github.com> Signed-off-by: Alexander Schwartz Co-authored-by: Alexander Schwartz --- .../tests/src/test/resources/ignored-links | 11 +-- .../topics/changes/changes-26_5_0.adoc | 6 +- .../resources/account/AccountRestService.java | 2 +- .../resources/account/SessionResource.java | 20 +++- .../account/AbstractRestServiceTest.java | 1 + .../account/AccountRestServiceTest.java | 99 +++++++++++++++++++ 6 files changed, 127 insertions(+), 12 deletions(-) diff --git a/docs/documentation/tests/src/test/resources/ignored-links b/docs/documentation/tests/src/test/resources/ignored-links index b99d67ab3cf..4b51689ba15 100644 --- a/docs/documentation/tests/src/test/resources/ignored-links +++ b/docs/documentation/tests/src/test/resources/ignored-links @@ -41,11 +41,10 @@ https://www.npmjs.com/package/* https://docs.kantarainitiative.org* https://saml.xml.org* -# To be removed once KC 26.4.2 is released +# To be removed once KC 26.5.0 is released +https://www.keycloak.org/observability/telemetry +https://www.keycloak.org/securing-apps/jwt-authorization-grant +https://www.keycloak.org/server/windows-service https://www.keycloak.org/server/db#multiple-datasources https://www.keycloak.org/server/logging#http-access-logging -https://www.keycloak.org/server/logging#mdc - -# To be removed once KC 26.5.0 is release -https://www.keycloak.org/observability/telemetry -https://www.keycloak.org/securing-apps/jwt-authorization-grant \ No newline at end of file +https://www.keycloak.org/server/logging#mdc \ No newline at end of file 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 0c623324999..8a0f9244bcd 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -39,8 +39,10 @@ only the basic attributes in representations or all of them. The `UserProfile` interface is a private API and should not be implemented by custom code. However, if you have extensions that implement this interface, you will need to update your code to accommodate this new method. -Breaking changes are identified as those that might require changes for existing users to their configurations or applications. -In minor or patch releases, {project_name} will only introduce breaking changes to fix bugs. +=== LOGOUT events when logging out sessions via the Account Console + +When logging out sessions via the Account Console {project_name} now creates LOGOUT user events to track this activity. +The events are connected to the `account` client. === Identity Provider refactoring diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 3a76203ea57..cc7769b1e25 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -210,7 +210,7 @@ public class AccountRestService { public SessionResource sessions() { checkAccountApiEnabled(); auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); - return new SessionResource(session, auth); + return new SessionResource(session, auth, event); } @Path("/credentials") 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 36b1c6663ee..472b05bf086 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 @@ -33,6 +33,8 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.keycloak.device.DeviceActivityManager; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; import org.keycloak.models.AccountRoles; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -56,12 +58,14 @@ public class SessionResource { private final Auth auth; private final RealmModel realm; private final UserModel user; + private final EventBuilder event; - public SessionResource(KeycloakSession session, Auth auth) { + public SessionResource(KeycloakSession session, Auth auth, EventBuilder event) { this.session = session; this.auth = auth; this.realm = auth.getRealm(); this.user = auth.getUser(); + this.event = event; } /** @@ -129,8 +133,14 @@ public class SessionResource { 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)); - + .forEach(s -> { + AuthenticationManager.backchannelLogout(session, s, true); + event.clone() + .event(EventType.LOGOUT) + .user(user) + .session(s) + .success(); + }); return Response.noContent().build(); } @@ -149,6 +159,10 @@ public class SessionResource { 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(); } return Response.noContent().build(); } 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 4ad97640139..16ccc5b395d 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,6 +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()); 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 e6d2c57b1c2..25e8ebbd307 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 @@ -46,6 +46,8 @@ import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.http.simple.SimpleHttpRequest; import org.keycloak.http.simple.SimpleHttpResponse; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.UserModel; @@ -55,6 +57,7 @@ import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.AccessToken; import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation; @@ -72,6 +75,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; import org.keycloak.representations.idm.UserProfileAttributeMetadata; +import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.services.cors.Cors; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.account.AccountCredentialResource; @@ -1144,6 +1148,101 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { assertEquals(1, sessions.size()); } + + @Test + public void testDeletionOfAllUserSessionsWillFireLogoutEvents() throws IOException { + String username = "manage-account-access"; + String password = "password"; + String firstToken = new TokenUtil(username, password).getToken(); + String secondToken = new TokenUtil(username, password).getToken(); + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username); + List userSessions = user.getUserSessions(); + assertEquals(2, userSessions.size()); + + // skip the two direct access grant logins + events.poll(); + events.poll(); + + int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=true"), httpClient).acceptJson().auth(firstToken).asStatus(); + assertEquals(204, status); + assertEquals(0, user.getUserSessions().size()); + + userSessions.forEach(session -> { + events.expectAccount(EventType.LOGOUT) + .user(user.toRepresentation().getId()) + .session(session.getId()) + .assertEvent(); + }); + + events.assertEmpty(); + } + + @Test + public void testDeletionOfAllUserSessionsExceptTheCurrentWillFireLogoutEvents() throws IOException, JWSInputException { + String username = "manage-account-access"; + String password = "password"; + String firstToken = new TokenUtil(username, password).getToken(); + String secondToken = new TokenUtil(username, password).getToken(); + String thirdToken = new TokenUtil(username, password).getToken(); + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username); + List userSessions = user.getUserSessions(); + assertEquals(3, userSessions.size()); + + // skip the three direct access grant logins + events.poll(); + events.poll(); + events.poll(); + + int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=false"), httpClient).acceptJson().auth(firstToken).asStatus(); + assertEquals(204, status); + assertEquals(1, user.getUserSessions().size()); + + JWSInput input = new JWSInput(firstToken); + AccessToken token = input.readJsonContent(AccessToken.class); + + userSessions = userSessions.stream().filter(session -> !session.getId().equals(token.getSessionId())).toList(); + + userSessions.forEach(session -> { + events.expectAccount(EventType.LOGOUT) + .user(user.toRepresentation().getId()) + .session(session.getId()) + .assertEvent(); + }); + + events.assertEmpty(); + } + + + @Test + public void testDeletionOfSpecificSessionWillFireLogoutEvent() throws IOException, JWSInputException { + String username = "manage-account-access"; + String password = "password"; + String firstToken = new TokenUtil(username, password).getToken(); + String secondToken = new TokenUtil(username, password).getToken(); + UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username); + List userSessions = user.getUserSessions(); + assertEquals(2, userSessions.size()); + + // skip the two direct access grant logins + events.poll(); + events.poll(); + + JWSInput input = new JWSInput(firstToken); + AccessToken token = input.readJsonContent(AccessToken.class); + + int status = SimpleHttpDefault.doDelete(getAccountUrl(String.format("sessions/%s", token.getSessionId())), httpClient) + .acceptJson().auth(firstToken).asStatus(); + assertEquals(204, status); + assertEquals(1, user.getUserSessions().size()); + + events.expectAccount(EventType.LOGOUT) + .user(user.toRepresentation().getId()) + .session(token.getSessionId()) + .assertEvent(); + + events.assertEmpty(); + } + @Test public void listApplications() throws Exception { oauth.client("in-use-client", "secret1");