Logout confirmation

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@gmail.com>
This commit is contained in:
Sebastian Łaskawiec
2025-11-28 10:38:35 +01:00
committed by Marek Posolda
parent f6676ccd76
commit aa789dd023
11 changed files with 80 additions and 5 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -167,6 +167,17 @@ export const LogoutPanel = ({
</FormGroup>
</>
)}
{protocol === "openid-connect" && (
<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(
"attributes.logout.confirmation.enabled",
)}
defaultValue="false"
label={t("logoutConfirmation")}
labelIcon={t("logoutConfirmationHelp")}
stringify
/>
)}
<FixedButtonsGroup
name="settings"
save={save}

View File

@@ -10,6 +10,7 @@ import { continueNext, createClient, save } from "./utils.ts";
import {
assertKeyForCodeExchangeInput,
selectKeyForCodeExchangeInput,
toggleLogoutConfirmation,
} from "./details.ts";
test.describe.serial("Clients details test", () => {
@@ -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");

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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();