Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/documentation/server_admin/topics/account.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions js/apps/account-ui/src/api/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ export async function deleteSession(
});
}

export async function deleteApplicationSessions(
context: KeycloakContext<BaseEnvironment>,
clientId: string,
) {
return request(
`/applications/${encodeURIComponent(clientId)}/sessions`,
context,
{
method: "DELETE",
},
);
}

export async function getCredentials({ signal, context }: CallOptions) {
const response = await request("/credentials", context, {
signal,
Expand Down
53 changes: 51 additions & 2 deletions js/apps/account-ui/src/applications/Applications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <Spinner />;
}
Expand Down Expand Up @@ -125,6 +139,11 @@ export const Applications = () => {
>
<strong>{t("status")}</strong>
</DataListCell>,
<DataListCell
key="applications-list-action-header"
width={2}
className="pf-v5-u-pt-md"
/>,
]}
/>
</DataListItemRow>
Expand Down Expand Up @@ -180,7 +199,37 @@ export const Applications = () => {
{application.offlineAccess ? ", " + t("offlineAccess") : ""}
</DataListCell>,
<DataListCell width={2} key={`status${application.clientId}`}>
{application.inUse ? t("inUse") : t("notInUse")}
{application.inUse || application.offlineAccess
? t("inUse")
: t("notInUse")}
</DataListCell>,
<DataListCell width={2} key={`action${application.clientId}`}>
{(application.inUse || application.offlineAccess) &&
application.clientId !== context.environment.clientId && (
<ContinueCancelModal
buttonTitle={t("endApplicationSession")}
modalTitle={t("endApplicationSession")}
continueLabel={t("confirm")}
cancelLabel={t("cancel")}
buttonVariant="secondary"
onContinue={() =>
endApplicationSession(
application.clientId,
label(
t,
application.clientName || application.clientId,
),
)
}
>
{t("endApplicationSessionMessage", {
name: label(
t,
application.clientName || application.clientId,
),
})}
</ContinueCancelModal>
)}
</DataListCell>,
]}
/>
Expand Down
88 changes: 88 additions & 0 deletions js/apps/account-ui/test/applications.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -391,6 +392,50 @@ public Response revokeConsent(final @PathParam("clientId") String clientId) {
return Response.noContent().build();
}

/**
* 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 afterward is removed.
*
* @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);
}

Stream.concat(session.sessions().getUserSessionsStream(realm, user), session.sessions().getOfflineUserSessionsStream(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.
Expand Down
Loading
Loading