From 4222de8f41f4ce7ea08a43e8901ad88faf85bc55 Mon Sep 17 00:00:00 2001 From: mposolda Date: Fri, 27 May 2022 16:38:35 +0200 Subject: [PATCH] OIDC RP-Initiated Logout POST method support Closes #11958 --- .../oidc/endpoints/LogoutEndpoint.java | 28 +++++++++-- .../rest/TestingResourceProvider.java | 2 +- .../org/keycloak/testsuite/util/URLUtils.java | 23 +++++++++ .../oauth/RPInitiatedLogoutTest.java | 49 +++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java index 5a8832789a..634e92c0af 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java @@ -289,6 +289,30 @@ public class LogoutEndpoint { .createLogoutConfirmPage(); } + /** + * This endpoint can be used either as: + * - OpenID Connect RP-Initiated Logout POST endpoint according to the specification https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout + * - Legacy Logout endpoint with refresh_token as an argument and client authentication needed. See {@link #logoutToken} for more details + * + * @return response + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response logout() { + MultivaluedMap form = request.getDecodedFormParameters(); + if (form.containsKey(OAuth2Constants.REFRESH_TOKEN)) { + return logoutToken(); + } else { + return logout(form.getFirst(OIDCLoginProtocol.REDIRECT_URI_PARAM), + form.getFirst(OIDCLoginProtocol.ID_TOKEN_HINT), + form.getFirst(OIDCLoginProtocol.CLIENT_ID_PARAM), + form.getFirst(OIDCLoginProtocol.POST_LOGOUT_REDIRECT_URI_PARAM), + form.getFirst(OIDCLoginProtocol.STATE_PARAM), + form.getFirst(OIDCLoginProtocol.UI_LOCALES_PARAM), + form.getFirst(AuthenticationManager.INITIATING_IDP_PARAM)); + } + } + @Path("/logout-confirm") @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -389,9 +413,7 @@ public class LogoutEndpoint { * * @return */ - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response logoutToken() { + private Response logoutToken() { cors = Cors.add(request).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); MultivaluedMap form = request.getDecodedFormParameters(); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index e482a7e723..937cd65243 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -1000,7 +1000,7 @@ public class TestingResourceProvider implements RealmResourceProvider { * See URLUtils.sendPOSTWithWebDriver for more details * * @param postRequestUrl Absolute URL. It can include query parameters etc. The POST request will be send to this URL - * @param encodedFormParameters Encoded parameters in the form of "param1=value1:param2=value2" + * @param encodedFormParameters Encoded parameters in the form of "param1=value1¶m2=value2" * @return */ @GET diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/URLUtils.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/URLUtils.java index a64eb8b4e7..705d824d4d 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/URLUtils.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/URLUtils.java @@ -11,6 +11,7 @@ import org.openqa.selenium.support.ui.WebDriverWait; import java.net.URI; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; import static org.keycloak.testsuite.util.DroneUtils.getCurrentDriver; @@ -96,6 +97,28 @@ public final class URLUtils { return true; } + /** + * @see #sendPOSTRequestWithWebDriver(String, String) + * + * @param postRequestUrl + * @param formParams form params in key/value form + */ + public static void sendPOSTRequestWithWebDriver(String postRequestUrl, Map formParams) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : formParams.entrySet()) { + if (first) { + first = false; + } else { + sb.append("&"); + } + sb.append(entry.getKey()) + .append("=") + .append(entry.getValue()); + } + sendPOSTRequestWithWebDriver(postRequestUrl, sb.toString()); + } + /** * This will send POST request to specified URL with specified form parameters. It's not easily possible to "trick" web driver to send POST * request with custom parameters, which are not directly available in the form. 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 8596ede8dc..fb1d441198 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 @@ -36,6 +36,7 @@ import org.keycloak.events.Errors; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.Constants; import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.IDToken; import org.keycloak.representations.LogoutToken; import org.keycloak.representations.idm.ClientRepresentation; @@ -52,6 +53,8 @@ import org.keycloak.testsuite.pages.LoginPage; import java.io.Closeable; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import javax.ws.rs.NotFoundException; import javax.ws.rs.core.HttpHeaders; @@ -76,6 +79,7 @@ import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; import org.keycloak.testsuite.util.Matchers; import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.URLUtils; import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.NoSuchElementException; @@ -721,6 +725,51 @@ public class RPInitiatedLogoutTest extends AbstractTestRealmKeycloakTest { } + // Calling RP-Initiated Logout endpoint with POST request. This must be supported according to specification + @Test + public void logoutWithPostRequest() throws IOException { + try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm()).addSupportedLocale("cs").update()) { + OAuthClient.AccessTokenResponse tokenResponse = loginUser(); + + // Logout with POST request and automatic redirect after logout + String redirectUri = APP_REDIRECT_URI + "?logout"; + + String idTokenString = tokenResponse.getIdToken(); + String sessionId = tokenResponse.getSessionState(); + + Map postParams = new HashMap<>(); + postParams.put(OIDCLoginProtocol.POST_LOGOUT_REDIRECT_URI_PARAM, redirectUri); + postParams.put(OIDCLoginProtocol.ID_TOKEN_HINT, idTokenString); + postParams.put(OAuth2Constants.STATE, "my-state"); + URLUtils.sendPOSTRequestWithWebDriver(oauth.getLogoutUrl().build(), postParams); + + events.expectLogout(tokenResponse.getSessionState()).detail(Details.REDIRECT_URI, redirectUri).assertEvent(); + Assert.assertThat(false, is(isSessionActive(sessionId))); + assertCurrentUrlEquals(redirectUri + "&state=my-state"); + + // Logout with showing confirmation screen + tokenResponse = loginUser(); + sessionId = tokenResponse.getSessionState(); + + postParams.clear(); + postParams.put(OIDCLoginProtocol.POST_LOGOUT_REDIRECT_URI_PARAM, redirectUri); + postParams.put(OAuth2Constants.CLIENT_ID, "test-app"); + postParams.put(OAuth2Constants.STATE, "my-state-2"); + postParams.put(OIDCLoginProtocol.UI_LOCALES_PARAM, "cs"); + URLUtils.sendPOSTRequestWithWebDriver(oauth.getLogoutUrl().build(), postParams); + + Assert.assertEquals("Odhlašování", PageUtils.getPageTitle(driver)); // Logging out + Assert.assertEquals("Čeština", logoutConfirmPage.getLanguageDropdownText()); + logoutConfirmPage.confirmLogout(); + + WaitUtils.waitForPageToLoad(); + events.expectLogout(tokenResponse.getSessionState()).detail(Details.REDIRECT_URI, redirectUri).assertEvent(); + Assert.assertThat(false, is(isSessionActive(sessionId))); + assertCurrentUrlEquals(redirectUri + "&state=my-state-2"); + } + } + + @Test public void testFrontChannelLogoutWithPostLogoutRedirectUri() throws Exception { ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();