From b001062dee6aeabd36973a1133729a9cce0ca496 Mon Sep 17 00:00:00 2001 From: Muhammed Oguz Date: Fri, 10 Apr 2026 00:30:45 +0300 Subject: [PATCH 1/3] Add per-application session termination to Account Console Allow users to end their active sessions with an individual application from the Applications page. Applications with an active session show an "End application session" action, backed by a new account REST API endpoint DELETE /applications/{clientId}/sessions. The endpoint detaches the client sessions from the user's online sessions, notifying the client via backchannel logout when supported, and removes user sessions that no longer have any client sessions. Offline sessions are not affected. The Account Console itself does not offer the action for its own client. Closes #47921 Signed-off-by: Muhammed Oguz --- .../server_admin/topics/account.adoc | 15 +++ .../account/messages/messages_tr.properties | 4 + .../account/messages/messages_en.properties | 4 + js/apps/account-ui/src/api/methods.ts | 9 ++ .../src/applications/Applications.tsx | 49 +++++++- js/apps/account-ui/test/applications.spec.ts | 88 ++++++++++++++ .../resources/account/AccountRestService.java | 45 +++++++ .../tests/account/AccountRestServiceTest.java | 111 ++++++++++++++++++ 8 files changed, 324 insertions(+), 1 deletion(-) diff --git a/docs/documentation/server_admin/topics/account.adoc b/docs/documentation/server_admin/topics/account.adoc index bac67c644ca4..5b886ee940ed 100644 --- a/docs/documentation/server_admin/topics/account.adoc +++ b/docs/documentation/server_admin/topics/account.adoc @@ -125,6 +125,21 @@ The *Applications* menu item shows users which applications you can access. In t image:images/account-console-applications.png[Applications] +==== Ending an application session + +You can end your active sessions with a specific application directly from the *Applications* page. Applications that have an active session are marked as *In use* and display an *End application session* button. The Account Console itself does not display this button. + +.Procedure + +. Click *Applications* in the menu. +. Find the application whose session you want to end. +. Click *End application session* next to the application. +. Click *Confirm* in the confirmation dialog. + +The sessions of the application for your account are ended. If the application supports it, {project_name} also notifies the application about the logout. Note that if your account still has an active session, for example because other applications are using it, the application might sign you in again automatically without asking for your credentials. To fully sign out from all applications, use the *Sign out* action on the *Device activity* page instead. + +Ending an application session does not revoke previously granted offline access. To revoke offline access, use the *Remove access* action for the application. + === Viewing group memberships You can view the groups you are associated with by clicking the *Groups* menu. diff --git a/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties b/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties index ace71c463b06..79eea92143fb 100644 --- a/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties +++ b/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties @@ -52,6 +52,10 @@ close=Kapat email=Email signOutWarning=Oturumu sonlandıralım mı? removeConsentError=Şu nedenlerle onay kaldırılamadı: {{error}} +endApplicationSession=Uygulama oturumunu sonlandır +endApplicationSessionSuccess={{name}} için uygulama oturumu sonlandırıldı +endApplicationSessionError=Şu nedenle uygulama oturumu sonlandırılamadı\: {{error}} +endApplicationSessionMessage=Bu işlem {{name}} ile olan aktif oturumlarınızı sonlandırır. Hesap oturumunuz hâlâ aktifse, uygulama kimlik bilgileriniz sorulmadan sizi otomatik olarak yeniden oturum açtırabilir. signOutAllDevicesWarning=Bu eylem, kullandığınız mevcut cihaz da dahil olmak üzere hesabınızda oturum açmış olan tüm cihazların oturumunu kapatacaktır. unShareSuccess=Kaynak başarıyla paylaşımdan kaldırıldı. signingIn=Oturum açma diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties index 882986fb4582..ddfb08c43bbd 100644 --- a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties @@ -55,6 +55,10 @@ close=Close email=Email signOutWarning=Sign out the session? removeConsentError=Could not remove consent due to\: {{error}} +endApplicationSession=End application session +endApplicationSessionSuccess=Ended the application session for {{name}} +endApplicationSessionError=Could not end the application session due to\: {{error}} +endApplicationSessionMessage=This ends your active sessions with {{name}}. If your account session is still active, the application might sign you in again automatically without asking for credentials. signOutAllDevicesWarning=This action will sign out all the devices that have signed in to your account, including the current device you are using. unShareSuccess=Resource successfully un-shared. signingIn=Signing in diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index 99d07957b57c..226adea07bec 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -105,6 +105,15 @@ export async function deleteSession( }); } +export async function deleteApplicationSessions( + context: KeycloakContext, + clientId: string, +) { + return request(`/applications/${clientId}/sessions`, context, { + method: "DELETE", + }); +} + export async function getCredentials({ signal, context }: CallOptions) { const response = await request("/credentials", context, { signal, diff --git a/js/apps/account-ui/src/applications/Applications.tsx b/js/apps/account-ui/src/applications/Applications.tsx index 1479bdfbc486..fb75851d521a 100644 --- a/js/apps/account-ui/src/applications/Applications.tsx +++ b/js/apps/account-ui/src/applications/Applications.tsx @@ -29,7 +29,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AccountEnvironment } from ".."; -import { deleteConsent, getApplications } from "../api/methods"; +import { + deleteApplicationSessions, + deleteConsent, + getApplications, +} from "../api/methods"; import { ClientRepresentation } from "../api/representations"; import { Page } from "../components/page/Page"; import type { TFuncKey } from "../i18n-type"; @@ -84,6 +88,16 @@ export const Applications = () => { } }; + const endApplicationSession = async (id: string, name: string) => { + try { + await deleteApplicationSessions(context, id); + refresh(); + addAlert(t("endApplicationSessionSuccess", { name })); + } catch (error) { + addError("endApplicationSessionError", error); + } + }; + if (!applications) { return ; } @@ -125,6 +139,11 @@ export const Applications = () => { > {t("status")} , + , ]} /> @@ -182,6 +201,34 @@ export const Applications = () => { {application.inUse ? t("inUse") : t("notInUse")} , + + {application.inUse && + application.clientId !== context.environment.clientId && ( + + endApplicationSession( + application.clientId, + label( + t, + application.clientName || application.clientId, + ), + ) + } + > + {t("endApplicationSessionMessage", { + name: label( + t, + application.clientName || application.clientId, + ), + })} + + )} + , ]} /> diff --git a/js/apps/account-ui/test/applications.spec.ts b/js/apps/account-ui/test/applications.spec.ts index f3078fb50ee4..4313da019b4e 100644 --- a/js/apps/account-ui/test/applications.spec.ts +++ b/js/apps/account-ui/test/applications.spec.ts @@ -53,4 +53,92 @@ test.describe("Applications", () => { await expect(applications.nth(1)).toContainText("Beta Application"); await expect(applications.nth(2)).toContainText("Zebra Application"); }); + + test("ends the session of an application", async ({ page }) => { + await using testBed = await createTestBed(); + + // Mock an application with an active session, the backend behavior is covered by AccountRestServiceTest. + await page.route("**/applications", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + clientId: "my-application", + clientName: "My Application", + inUse: true, + }, + ]), + }); + }); + + let sessionsDeleted = false; + await page.route( + "**/applications/my-application/sessions", + async (route) => { + expect(route.request().method()).toBe("DELETE"); + sessionsDeleted = true; + await route.fulfill({ status: 204 }); + }, + ); + + // Log in and navigate to the applications page. + await login(page, testBed.realm); + await page.getByTestId("applications").click(); + + // The application has an active session, so the action is shown. + const endSessionButton = page.getByRole("button", { + name: "End application session", + exact: true, + }); + await expect(endSessionButton).toBeVisible(); + + // Click the action and confirm the modal. + await endSessionButton.click(); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + + // Expect a success alert after the sessions have been deleted. + await expect(page.getByTestId("last-alert")).toContainText( + "Ended the application session for My Application", + ); + expect(sessionsDeleted).toBe(true); + }); + + test("does not show the end session action for applications without an active session or for the Account Console itself", async ({ + page, + }) => { + await using testBed = await createTestBed(); + + await page.route("**/applications", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + clientId: "account-console", + clientName: "Account Console", + inUse: true, + }, + { + clientId: "other-application", + clientName: "Other Application", + inUse: false, + }, + ]), + }); + }); + + // Log in and navigate to the applications page. + await login(page, testBed.realm); + await page.getByTestId("applications").click(); + + // Neither the Account Console itself nor an application without an active session shows the action. + await expect(page.getByTestId("applications-list-item")).toHaveCount(2); + await expect( + page.getByRole("button", { + name: "End application session", + exact: true, + }), + ).toHaveCount(0); + }); }); 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 5e8493dd535b..357817e86967 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 @@ -71,6 +71,7 @@ import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.managers.Auth; +import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.UserConsentManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.account.resources.ResourcesService; @@ -391,6 +392,50 @@ public Response revokeConsent(final @PathParam("clientId") String clientId) { return Response.noContent().build(); } + /** + * Ends all active online sessions of the user for the client with the given client id. + * The client sessions are detached from the user sessions, and the client is notified + * via backchannel logout when supported. A user session that no longer has any client + * sessions afterwards is removed. Offline sessions are not affected. + * + * @param clientId client id to end the sessions for + * @return returns 204 if the sessions were ended + */ + @Path("/applications/{clientId}/sessions") + @DELETE + @NoCache + public Response revokeApplicationSessions(final @PathParam("clientId") String clientId) { + checkAccountApiEnabled(); + auth.require(AccountRoles.MANAGE_ACCOUNT); + + event.event(EventType.LOGOUT); + ClientModel client = realm.getClientByClientId(clientId); + if (client == null) { + String msg = String.format("No client with clientId: %s found.", clientId); + event.error(msg); + throw ErrorResponse.error(msg, Response.Status.NOT_FOUND); + } + + session.sessions().getUserSessionsStream(realm, user) + .filter(userSession -> userSession.getAuthenticatedClientSessionByClient(client.getId()) != null) + .toList() // collect to avoid concurrent modification. + .forEach(userSession -> { + AuthenticationManager.backchannelLogoutUserSessionFromClient(session, realm, userSession, client, + session.getContext().getUri(), headers); + if (userSession.getAuthenticatedClientSessions().isEmpty()) { + // The last client session was ended, remove the now empty user session as well. + session.sessions().removeUserSession(realm, userSession); + } + event.clone() + .event(EventType.LOGOUT) + .detail(Details.REVOKED_CLIENT, client.getClientId()) + .session(userSession) + .success(); + }); + + return Response.noContent().build(); + } + /** * Creates or updates the consent of the given, requested consent for * the client with the given client id. Returns the appropriate REST response. diff --git a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceTest.java b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceTest.java index 6f4b4d7da354..0b32600513f3 100644 --- a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceTest.java @@ -1282,6 +1282,117 @@ public void listApplicationsOfflineAccess() throws Exception { assertClientRep(apps.get("offline-client-without-base-url"), "Offline Client Without Base URL", null, false, false, true, null, null); } + @Test + public void revokeApplicationSessions() throws Exception { + managedRealm.cleanup().add(RealmResource::logoutAll); + + // Create a session for "in-use-client" via direct grant + oauth.client("in-use-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("manage-account-access", "password"); + Assertions.assertNull(tokenResponse.getErrorDescription()); + + String token = oauth.client("direct-grant", "password").doPasswordGrantRequest("manage-account-access", "password").getAccessToken(); + + // skip the two direct access grant logins + events.skip(2); + + UserResource user = AdminApiUtil.findUserByUsernameId(managedRealm.admin(), "manage-account-access"); + Assertions.assertEquals(2, user.getUserSessions().size()); + + // Verify the client is "in use" + List applications = simpleHttp + .doGet(getAccountUrl("applications")) + .header("Accept", "application/json") + .auth(token) + .asJson(new TypeReference>() { + }); + Map apps = applications.stream().collect(Collectors.toMap(x -> x.getClientId(), x -> x)); + Assertions.assertTrue(apps.get("in-use-client").isInUse()); + + // End the application sessions + try (SimpleHttpResponse response = simpleHttp + .doDelete(getAccountUrl("applications/in-use-client/sessions")) + .header("Accept", "application/json") + .auth(token) + .asResponse()) { + Assertions.assertEquals(204, response.getStatus()); + } + + // A logout event is fired for the session of "in-use-client" + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGOUT) + .clientId("account") + .userId(user.toRepresentation().getId()) + .sessionId(tokenResponse.getSessionState()) + .details(Details.REVOKED_CLIENT, "in-use-client"); + Assertions.assertNull(events.poll()); + + // The user session of "in-use-client" had no other client sessions and was removed as well + List userSessions = user.getUserSessions(); + Assertions.assertEquals(1, userSessions.size()); + Assertions.assertNotEquals(tokenResponse.getSessionState(), userSessions.get(0).getId()); + + // Verify the client is no longer "in use" + applications = simpleHttp + .doGet(getAccountUrl("applications")) + .header("Accept", "application/json") + .auth(token) + .asJson(new TypeReference>() { + }); + apps = applications.stream().collect(Collectors.toMap(x -> x.getClientId(), x -> x)); + Assertions.assertFalse(apps.containsKey("in-use-client") && apps.get("in-use-client").isInUse()); + } + + @Test + public void revokeApplicationSessionsForNotExistingClient() throws IOException { + managedRealm.cleanup().add(RealmResource::logoutAll); + String token = oauth.client("direct-grant", "password").doPasswordGrantRequest("manage-account-access", "password").getAccessToken(); + try (SimpleHttpResponse response = simpleHttp + .doDelete(getAccountUrl("applications/not-existing/sessions")) + .header("Accept", "application/json") + .auth(token) + .asResponse()) { + Assertions.assertEquals(404, response.getStatus()); + } + } + + @Test + public void revokeApplicationSessionsWithoutPermission() throws IOException { + managedRealm.cleanup().add(RealmResource::logoutAll); + String token = oauth.client("direct-grant", "password").doPasswordGrantRequest("view-account-access", "password").getAccessToken(); + try (SimpleHttpResponse response = simpleHttp + .doDelete(getAccountUrl("applications/in-use-client/sessions")) + .header("Accept", "application/json") + .auth(token) + .asResponse()) { + Assertions.assertEquals(403, response.getStatus()); + } + } + + @Test + public void revokeApplicationSessionsIdempotent() throws IOException { + managedRealm.cleanup().add(RealmResource::logoutAll); + String token = oauth.client("direct-grant", "password").doPasswordGrantRequest("manage-account-access", "password").getAccessToken(); + + // Delete sessions when there are none -- should still return 204 + try (SimpleHttpResponse response = simpleHttp + .doDelete(getAccountUrl("applications/in-use-client/sessions")) + .header("Accept", "application/json") + .auth(token) + .asResponse()) { + Assertions.assertEquals(204, response.getStatus()); + } + + // Call again -- still 204 + try (SimpleHttpResponse response = simpleHttp + .doDelete(getAccountUrl("applications/in-use-client/sessions")) + .header("Accept", "application/json") + .auth(token) + .asResponse()) { + Assertions.assertEquals(204, response.getStatus()); + } + } + @Test public void listApplicationsThirdPartyWithoutConsentText() throws Exception { listApplicationsThirdParty("acr", false); From d94947266074231e4dc6c42237c4677bf5516735 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Mon, 10 Aug 2026 17:12:46 +0900 Subject: [PATCH 2/3] Review: Allow removal of offline sessions, defer Turkish translation Signed-off-by: Alexander Schwartz --- .../keycloak.v3/account/messages/messages_tr.properties | 4 ---- js/apps/account-ui/src/applications/Applications.tsx | 6 ++++-- .../services/resources/account/AccountRestService.java | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties b/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties index 79eea92143fb..ace71c463b06 100644 --- a/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties +++ b/js/apps/account-ui/maven-resources-community/theme/keycloak.v3/account/messages/messages_tr.properties @@ -52,10 +52,6 @@ close=Kapat email=Email signOutWarning=Oturumu sonlandıralım mı? removeConsentError=Şu nedenlerle onay kaldırılamadı: {{error}} -endApplicationSession=Uygulama oturumunu sonlandır -endApplicationSessionSuccess={{name}} için uygulama oturumu sonlandırıldı -endApplicationSessionError=Şu nedenle uygulama oturumu sonlandırılamadı\: {{error}} -endApplicationSessionMessage=Bu işlem {{name}} ile olan aktif oturumlarınızı sonlandırır. Hesap oturumunuz hâlâ aktifse, uygulama kimlik bilgileriniz sorulmadan sizi otomatik olarak yeniden oturum açtırabilir. signOutAllDevicesWarning=Bu eylem, kullandığınız mevcut cihaz da dahil olmak üzere hesabınızda oturum açmış olan tüm cihazların oturumunu kapatacaktır. unShareSuccess=Kaynak başarıyla paylaşımdan kaldırıldı. signingIn=Oturum açma diff --git a/js/apps/account-ui/src/applications/Applications.tsx b/js/apps/account-ui/src/applications/Applications.tsx index fb75851d521a..46eb771e8a61 100644 --- a/js/apps/account-ui/src/applications/Applications.tsx +++ b/js/apps/account-ui/src/applications/Applications.tsx @@ -199,10 +199,12 @@ export const Applications = () => { {application.offlineAccess ? ", " + t("offlineAccess") : ""} , - {application.inUse ? t("inUse") : t("notInUse")} + {application.inUse || application.offlineAccess + ? t("inUse") + : t("notInUse")} , - {application.inUse && + {(application.inUse || application.offlineAccess) && application.clientId !== context.environment.clientId && ( userSession.getAuthenticatedClientSessionByClient(client.getId()) != null) .toList() // collect to avoid concurrent modification. .forEach(userSession -> { From f9d591c74b1c841e77749cba06623be584190f21 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Mon, 10 Aug 2026 17:33:17 +0900 Subject: [PATCH 3/3] Resolving GitHub Copilot comment Signed-off-by: Alexander Schwartz --- js/apps/account-ui/src/api/methods.ts | 10 +++++++--- .../services/resources/account/AccountRestService.java | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index 226adea07bec..08c5856e499e 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -109,9 +109,13 @@ export async function deleteApplicationSessions( context: KeycloakContext, clientId: string, ) { - return request(`/applications/${clientId}/sessions`, context, { - method: "DELETE", - }); + return request( + `/applications/${encodeURIComponent(clientId)}/sessions`, + context, + { + method: "DELETE", + }, + ); } export async function getCredentials({ signal, context }: CallOptions) { 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 45ceb27f737a..63423ad38617 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 @@ -396,7 +396,7 @@ public Response revokeConsent(final @PathParam("clientId") String clientId) { * Ends all active online and offline sessions of the user for the client with the given client id. * The client sessions are detached from the user sessions, and the client is notified * via backchannel logout when supported. A user session that no longer has any client - * sessions afterwards is removed. Offline sessions are not affected. + * sessions afterward is removed. * * @param clientId client id to end the sessions for * @return returns 204 if the sessions were ended