From aa789dd02343a9e50b0220beba10180f3d134593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20=C5=81askawiec?= Date: Fri, 28 Nov 2025 10:38:35 +0100 Subject: [PATCH] Logout confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sebastian Łaskawiec --- .../release_notes/topics/26_5_0.adoc | 4 +++ .../clients/oidc/con-basic-settings.adoc | 3 ++ .../sso-protocols/con-oidc-auth-flows.adoc | 1 + .../admin/messages/messages_en.properties | 2 ++ .../admin-ui/src/clients/add/LogoutPanel.tsx | 11 +++++++ js/apps/admin-ui/test/clients/details.spec.ts | 2 ++ js/apps/admin-ui/test/clients/details.ts | 8 ++++- .../protocol/oidc/OIDCConfigAttributes.java | 2 ++ .../oidc/OIDCAdvancedConfigWrapper.java | 8 +++++ .../protocol/oidc/utils/LogoutUtil.java | 15 +++++++--- .../oauth/RPInitiatedLogoutTest.java | 29 +++++++++++++++++++ 11 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/documentation/release_notes/topics/26_5_0.adoc b/docs/documentation/release_notes/topics/26_5_0.adoc index c04e47eb7b9..9548926ecc6 100644 --- a/docs/documentation/release_notes/topics/26_5_0.adoc +++ b/docs/documentation/release_notes/topics/26_5_0.adoc @@ -60,3 +60,7 @@ Organization administrators can now manage organization invitations through both All invitations are now persistently stored in the database, providing better tracking and management capabilities. The invitation management features are available in the *Invitations* tab when managing an organization in the Admin Console, and through the Organizations REST API endpoints under `/admin/realms/{realm}/orgs/{orgId}/invitations`. + += Logout confirmation + +The client logout configuration page now includes an option to enable logout confirmation. When enabled, users will see "You are logged out" confirmation page upon successful logout. diff --git a/docs/documentation/server_admin/topics/clients/oidc/con-basic-settings.adoc b/docs/documentation/server_admin/topics/clients/oidc/con-basic-settings.adoc index 802cba32c4a..5f05e28daf9 100644 --- a/docs/documentation/server_admin/topics/clients/oidc/con-basic-settings.adoc +++ b/docs/documentation/server_admin/topics/clients/oidc/con-basic-settings.adoc @@ -128,3 +128,6 @@ There will be also one item on the consent screen about this client itself. Specifies whether a session ID Claim is included in the Logout Token when the *Backchannel Logout URL* is used. *Backchannel logout revoke offline sessions*:: Specifies whether a revoke_offline_access event is included in the Logout Token when the Backchannel Logout URL is used. {project_name} will revoke offline sessions when receiving a Logout Token with this event. + +[[_logout-confirmation]] +*Logout confirmation*:: When enabled, {project_name} displays a confirmation page to the user after a successful logout that reads “You are logged out”. This setting primarily affects browser-based logouts, including xref:_oidc-logout[OIDC Logout] initiated by the client (RP-Initiated Logout). If a `post_logout_redirect_uri` is provided and validated for this client, the confirmation page includes a link (or button) to continue to that URL instead of redirecting automatically. diff --git a/docs/documentation/server_admin/topics/sso-protocols/con-oidc-auth-flows.adoc b/docs/documentation/server_admin/topics/sso-protocols/con-oidc-auth-flows.adoc index c3f45918340..2accf39fe95 100644 --- a/docs/documentation/server_admin/topics/sso-protocols/con-oidc-auth-flows.adoc +++ b/docs/documentation/server_admin/topics/sso-protocols/con-oidc-auth-flows.adoc @@ -340,6 +340,7 @@ This redirect usually happens when the user clicks the `Log Out` link on the pag Once the user is redirected to the logout endpoint, {project_name} is going to send logout requests to clients attached to the current browser SSO session to let them invalidate their local user sessions, and potentially redirect the user to some URL once the logout process is finished. The user might be optionally requested to confirm the logout in case the `id_token_hint` parameter was not used. +If the client has xref:_logout-confirmation[Logout confirmation] enabled, {project_name} renders a confirmation page after a successful logout informing the user that they are logged out. When a valid `post_logout_redirect_uri` is provided, this page includes an option to continue to that URL. After logout, the user is automatically redirected to the specified `post_logout_redirect_uri` as long as it is provided as a parameter. Note that you need to include either the `client_id` or `id_token_hint` parameter in case the `post_logout_redirect_uri` is included. Also the `post_logout_redirect_uri` parameter needs to match one of the `Valid Post Logout Redirect URIs` specified in the client configuration. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index ee1cfc7a4d0..1a0657c2465 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -1325,6 +1325,8 @@ generatedAccessTokenHelp=See the example access token, which will be generated a webAuthnPolicyAcceptableAaguidsHelp=The list of allowed AAGUIDs of which an authenticator can be registered. An AAGUID is a 128-bit identifier indicating the authenticator's type (e.g., make and model). keyPasswordHelp=Password for the private key frontchannelLogout=Front channel logout +logoutConfirmation=Logout confirmation +logoutConfirmationHelp=After logout of the user (OIDC RP-Initiated logout), there will be additional confirmation info page displayed to the user with the text like 'You are logged out' before redirecting a user to a post-logout landing page. On this info page, user needs to confirm that he want to be redirected to the post-logout landing page. clientUpdaterTrustedHostsTooltip=List of Hosts, which are trusted. If that client registration or update request comes from the host/domain specified in this configuration, the condition evaluates to true. You can use hostnames or IP addresses. If you use star at the beginning (for example '*.example.com'), the whole domain example.com is trusted. titleRoles=Realm roles sectorIdentifierUri.tooltip=Providers that use pairwise sub values and support Dynamic Client Registration SHOULD use the sector_identifier_uri parameter. It provides a way for a group of websites under common administrative control to have consistent pairwise sub values independent of the individual domain names. It also provides a way for Clients to change redirect_uri domains without having to reregister all their users. diff --git a/js/apps/admin-ui/src/clients/add/LogoutPanel.tsx b/js/apps/admin-ui/src/clients/add/LogoutPanel.tsx index 99aa1e6b7cf..a36d3bca6d6 100644 --- a/js/apps/admin-ui/src/clients/add/LogoutPanel.tsx +++ b/js/apps/admin-ui/src/clients/add/LogoutPanel.tsx @@ -167,6 +167,17 @@ export const LogoutPanel = ({ )} + {protocol === "openid-connect" && ( + ( + "attributes.logout.confirmation.enabled", + )} + defaultValue="false" + label={t("logoutConfirmation")} + labelIcon={t("logoutConfirmationHelp")} + stringify + /> + )} { @@ -69,6 +70,7 @@ test.describe.serial("Clients details test", () => { test("Should be able to update a client", async ({ page }) => { await clickTableRowItem(page, clientId); await selectKeyForCodeExchangeInput(page, "S256"); + await toggleLogoutConfirmation(page); await save(page); await assertNotificationMessage(page, "Client successfully updated"); await assertKeyForCodeExchangeInput(page, "S256"); diff --git a/js/apps/admin-ui/test/clients/details.ts b/js/apps/admin-ui/test/clients/details.ts index 280035c9881..1277f8c24dd 100644 --- a/js/apps/admin-ui/test/clients/details.ts +++ b/js/apps/admin-ui/test/clients/details.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { selectItem, assertSelectValue } from "../utils/form.ts"; +import { selectItem, assertSelectValue, switchToggle } from "../utils/form.ts"; function getKeyForCodeExchangeInput(page: Page) { return page.locator("#keyForCodeExchange"); @@ -12,3 +12,9 @@ export async function selectKeyForCodeExchangeInput(page: Page, value: string) { export async function assertKeyForCodeExchangeInput(page: Page, value: string) { await assertSelectValue(getKeyForCodeExchangeInput(page), value); } + +export async function toggleLogoutConfirmation(page: Page) { + const logoutConfirmationSwitch = + "#attributes\\.logout🍺confirmation🍺enabled"; + await switchToggle(page, logoutConfirmationSwitch); +} diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index d00f4390cf3..0f069c50a7e 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -72,6 +72,8 @@ public final class OIDCConfigAttributes { public static final String BACKCHANNEL_LOGOUT_REVOKE_OFFLINE_TOKENS = "backchannel.logout.revoke.offline.tokens"; + public static final String LOGOUT_CONFIRMATION_ENABLED = "logout.confirmation.enabled"; + public static final String USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT = "client_credentials.use_refresh_token"; public static final String USE_REFRESH_TOKEN = "use.refresh.tokens"; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index 8b59f90fbf3..e056da4cafa 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -380,6 +380,14 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper { setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_MAX_EXP, String.valueOf(maxExp)); } + public boolean isLogoutConfirmationEnabled() { + return Boolean.parseBoolean(getAttribute(OIDCConfigAttributes.LOGOUT_CONFIRMATION_ENABLED, "false")); + } + + public void setLogoutConfirmationEnabled(boolean enabled) { + setAttribute(OIDCConfigAttributes.LOGOUT_CONFIRMATION_ENABLED, String.valueOf(enabled)); + } + public String getBackchannelLogoutUrl() { return getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java index 1135ef226c7..1ed944c481a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java @@ -26,6 +26,7 @@ import jakarta.ws.rs.core.UriBuilder; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.utils.SystemClientUtil; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; @@ -39,14 +40,20 @@ public class LogoutUtil { public static Response sendResponseAfterLogoutFinished(KeycloakSession session, AuthenticationSessionModel logoutSession) { String redirectUri = logoutSession.getAuthNote(OIDCLoginProtocol.LOGOUT_REDIRECT_URI); - if (redirectUri != null) { - URI finalRedirectUri = getRedirectUriWithAttachedState(redirectUri, logoutSession); - return Response.status(302).location(finalRedirectUri).build(); + URI finalRedirectUri = getRedirectUriWithAttachedState(redirectUri, logoutSession); + OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientModel(logoutSession.getClient()); + LoginFormsProvider loginFormsProvider = session.getProvider(LoginFormsProvider.class); + + if (finalRedirectUri != null) { + if (!config.isLogoutConfirmationEnabled()) { + return Response.status(302).location(finalRedirectUri).build(); + } + loginFormsProvider.setAttribute("pageRedirectUri", finalRedirectUri.toString()); } SystemClientUtil.checkSkipLink(session, logoutSession); - return session.getProvider(LoginFormsProvider.class) + return loginFormsProvider .setSuccess(Messages.SUCCESS_LOGOUT) .setDetachedAuthSession() .createInfoPage(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java index 2a874f7bdc5..8627033201f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java @@ -161,6 +161,35 @@ public class RPInitiatedLogoutTest extends AbstractTestRealmKeycloakTest { assertCurrentUrlEquals(redirectUri + "&state=something"); } + @Test + public void logoutRedirectWithLogoutConfirmationEnabled() throws Exception { + try (ClientAttributeUpdater ignore = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") + .setAttribute(OIDCConfigAttributes.LOGOUT_CONFIRMATION_ENABLED, "true") + .update()) { + AccessTokenResponse tokenResponse = loginUser(); + String sessionId = tokenResponse.getSessionState(); + + String redirectUri = APP_REDIRECT_URI + "?logout"; + + String idTokenString = tokenResponse.getIdToken(); + + oauth.logoutForm().postLogoutRedirectUri(redirectUri).idTokenHint(idTokenString).open(); + + // Logout should be processed and session terminated + events.expectLogout(sessionId).detail(Details.REDIRECT_URI, redirectUri).assertEvent(); + MatcherAssert.assertThat(false, is(isSessionActive(sessionId))); + + // With logout confirmation enabled, an info page should be shown instead of an immediate redirect + infoPage.assertCurrent(); + Assert.assertEquals("You are logged out", infoPage.getInfo()); + + // The info page should contain a link back to application (redirectUri). Use the link to finish. + infoPage.clickBackToApplicationLink(); + WaitUtils.waitForPageToLoad(); + assertCurrentUrlEquals(redirectUri); + } + } + @Test public void postLogoutRedirect() { AccessTokenResponse tokenResponse = loginUser();