diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index d1750d2047..20d86c0404 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -267,9 +267,7 @@ public class SamlProtocol implements LoginProtocol { if (logoutPostUrl == null || logoutPostUrl.trim().isEmpty()) { // if we don't have a redirect uri either, return true and default to the admin url + POST binding - if (logoutRedirectUrl == null || logoutRedirectUrl.trim().isEmpty()) - return true; - return false; + return (logoutRedirectUrl == null || logoutRedirectUrl.trim().isEmpty()); } if (samlClient.forcePostBinding()) { @@ -282,11 +280,8 @@ public class SamlProtocol implements LoginProtocol { if (SAML_POST_BINDING.equals(bindingType)) return true; - if (logoutRedirectUrl == null || logoutRedirectUrl.trim().isEmpty()) - return true; // we don't have a redirect binding url, so use post binding - - return false; // redirect binding - + // true if we don't have a redirect binding url, so use post binding, false for redirect binding + return (logoutRedirectUrl == null || logoutRedirectUrl.trim().isEmpty()); } protected String getNameIdFormat(SamlClient samlClient, ClientSessionModel clientSession) { @@ -529,15 +524,20 @@ public class SamlProtocol implements LoginProtocol { if (!(client instanceof ClientModel)) return null; try { - if (isLogoutPostBindingForClient(clientSession)) { - String bindingUri = getLogoutServiceUrl(uriInfo, client, SAML_POST_BINDING); + boolean postBinding = isLogoutPostBindingForClient(clientSession); + String bindingUri = getLogoutServiceUrl(uriInfo, client, postBinding ? SAML_POST_BINDING : SAML_REDIRECT_BINDING); + if (bindingUri == null) { + logger.warnf("Failed to logout client %s, skipping this client. Please configure the logout service url in the admin console for your client applications.", client.getClientId()); + return null; + } + + if (postBinding) { SAML2LogoutRequestBuilder logoutBuilder = createLogoutRequest(bindingUri, clientSession, client); // This is POST binding, hence KeyID is included in dsig:KeyInfo/dsig:KeyName, no need to add element JaxrsSAML2BindingBuilder binding = createBindingBuilder(samlClient); return binding.postBinding(logoutBuilder.buildDocument()).request(bindingUri); } else { logger.debug("frontchannel redirect binding"); - String bindingUri = getLogoutServiceUrl(uriInfo, client, SAML_REDIRECT_BINDING); SAML2LogoutRequestBuilder logoutBuilder = createLogoutRequest(bindingUri, clientSession, client); if (samlClient.requiresRealmSignature() && samlClient.addExtensionsElementWithKeyInfo()) { KeyManager.ActiveRsaKey keys = session.keys().getActiveRsaKey(realm); @@ -620,7 +620,7 @@ public class SamlProtocol implements LoginProtocol { SamlClient samlClient = new SamlClient(client); String logoutUrl = getLogoutServiceUrl(uriInfo, client, SAML_POST_BINDING); if (logoutUrl == null) { - logger.warnv("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: {1}", client.getClientId()); + logger.warnf("Can't do backchannel logout. No SingleLogoutService POST Binding registered for client: %s", client.getClientId()); return; } SAML2LogoutRequestBuilder logoutBuilder = createLogoutRequest(logoutUrl, clientSession, client); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java index b9a2547ca7..6f68908c1f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java @@ -26,6 +26,9 @@ public abstract class AbstractSamlTest extends AbstractAuthTest { protected static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST = "http://localhost:8080/sales-post/"; protected static final String SAML_CLIENT_ID_SALES_POST = "http://localhost:8081/sales-post/"; + protected static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST2 = "http://localhost:8080/sales-post2/"; + protected static final String SAML_CLIENT_ID_SALES_POST2 = "http://localhost:8081/sales-post2/"; + protected static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST_ENC = "http://localhost:8080/sales-post-enc/"; protected static final String SAML_CLIENT_ID_SALES_POST_ENC = "http://localhost:8081/sales-post-enc/"; protected static final String SAML_CLIENT_SALES_POST_ENC_PRIVATE_KEY = "MIICXQIBAAKBgQDb7kwJPkGdU34hicplwfp6/WmNcaLh94TSc7Jyr9Undp5pkyLgb0DE7EIE+6kSs4LsqCb8HDkB0nLD5DXbBJFd8n0WGoKstelvtg6FtVJMnwN7k7yZbfkPECWH9zF70VeOo9vbzrApNRnct8ZhH5fbflRB4JMA9L9R+LbURdoSKQIDAQABAoGBANtbZG9bruoSGp2s5zhzLzd4hczT6Jfk3o9hYjzNb5Z60ymN3Z1omXtQAdEiiNHkRdNxK+EM7TcKBfmoJqcaeTkW8cksVEAW23ip8W9/XsLqmbU2mRrJiKa+KQNDSHqJi1VGyimi4DDApcaqRZcaKDFXg2KDr/Qt5JFD/o9IIIPZAkEA+ZENdBIlpbUfkJh6Ln+bUTss/FZ1FsrcPZWu13rChRMrsmXsfzu9kZUWdUeQ2Dj5AoW2Q7L/cqdGXS7Mm5XhcwJBAOGZq9axJY5YhKrsksvYRLhQbStmGu5LG75suF+rc/44sFq+aQM7+oeRr4VY88Mvz7mk4esdfnk7ae+cCazqJvMCQQCx1L1cZw3yfRSn6S6u8XjQMjWE/WpjulujeoRiwPPY9WcesOgLZZtYIH8nRL6ehEJTnMnahbLmlPFbttxPRUanAkA11MtSIVcKzkhp2KV2ipZrPJWwI18NuVJXb+3WtjypTrGWFZVNNkSjkLnHIeCYlJIGhDd8OL9zAiBXEm6kmgLNAkBWAg0tK2hCjvzsaA505gWQb4X56uKWdb0IzN+fOLB3Qt7+fLqbVQNQoNGzqey6B4MoS1fUKAStqdGTFYPG/+9t"; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java new file mode 100644 index 0000000000..7870ebaa18 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java @@ -0,0 +1,264 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.saml; + +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AuthnStatementType; +import org.keycloak.dom.saml.v2.assertion.NameIDType; +import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; +import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.saml.SAML2LogoutRequestBuilder; +import org.keycloak.saml.SAML2LogoutResponseBuilder; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.SamlClient; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilderException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.junit.Before; +import org.junit.Test; +import org.w3c.dom.Document; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertThat; +import static org.keycloak.testsuite.util.Matchers.*; +import static org.keycloak.testsuite.util.SamlClient.Binding.*; + +/** + * + * @author hmlnarik + */ +public class LogoutTest extends AbstractSamlTest { + + private ClientRepresentation salesRep; + private ClientRepresentation sales2Rep; + + private SamlClient samlClient; + + @Before + public void setup() { + salesRep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST).get(0); + sales2Rep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST2).get(0); + + adminClient.realm(REALM_NAME) + .clients().get(salesRep.getId()) + .update(ClientBuilder.edit(salesRep) + .frontchannelLogout(true) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "http://url") + .build()); + + samlClient = new SamlClient(getAuthServerSamlEndpoint(REALM_NAME)); + } + + @Override + protected boolean isImportAfterEachMethod() { + return true; + } + + private Document prepareLogoutFromSalesAfterLoggingIntoTwoApps() throws ParsingException, IllegalArgumentException, UriBuilderException, ConfigurationException, ProcessingException { + AuthnRequestType loginRep = createLoginRequestDocument(SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, REALM_NAME); + Document doc = SAML2Request.convert(loginRep); + SAMLDocumentHolder resp = samlClient.login(bburkeUser, doc, null, POST, POST, false, true); + assertThat(resp.getSamlObject(), isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + ResponseType loginResp1 = (ResponseType) resp.getSamlObject(); + + loginRep = createLoginRequestDocument(SAML_CLIENT_ID_SALES_POST2, SAML_ASSERTION_CONSUMER_URL_SALES_POST2, REALM_NAME); + doc = SAML2Request.convert(loginRep); + resp = samlClient.subsequentLoginViaSSO(doc, null, POST, POST); + assertThat(resp.getSamlObject(), isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + ResponseType loginResp2 = (ResponseType) resp.getSamlObject(); + + AssertionType firstAssertion = loginResp1.getAssertions().get(0).getAssertion(); + assertThat(firstAssertion.getSubject().getSubType().getBaseID(), instanceOf(NameIDType.class)); + NameIDType nameId = (NameIDType) firstAssertion.getSubject().getSubType().getBaseID(); + AuthnStatementType firstAssertionStatement = (AuthnStatementType) firstAssertion.getStatements().iterator().next(); + + return new SAML2LogoutRequestBuilder() + .destination(getAuthServerSamlEndpoint(REALM_NAME).toString()) + .issuer(SAML_CLIENT_ID_SALES_POST) + .sessionIndex(firstAssertionStatement.getSessionIndex()) + .userPrincipal(nameId.getValue(), nameId.getFormat().toString()) + .buildDocument(); + } + + @Test + public void testLogoutInSameBrowser() throws ParsingException, ConfigurationException, ProcessingException { + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(false) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "") + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE) + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.logout(logoutDoc, null, POST, POST); + } + + @Test + public void testLogoutDifferentBrowser() throws ParsingException, ConfigurationException, ProcessingException { + // This is in fact the same as admin logging out a session from admin console. + // This always succeeds as it is essentially the same as backend logout which + // does not report errors to client but only to the server log + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(false) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "") + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE) + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.execute((client, context, strategy) -> { + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + CloseableHttpResponse response = client.execute(post, HttpClientContext.create()); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @Test + public void testFrontchannelLogoutInSameBrowser() throws ParsingException, ConfigurationException, ProcessingException { + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(true) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "") + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE) + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.execute((client, context, strategy) -> { + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + CloseableHttpResponse response = client.execute(post, context); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @Test + public void testFrontchannelLogoutNoLogoutServiceUrlSetInSameBrowser() throws ParsingException, ConfigurationException, ProcessingException { + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(true) + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE) + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE) + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.execute((client, context, strategy) -> { + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + CloseableHttpResponse response = client.execute(post, context); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @Test + public void testFrontchannelLogoutDifferentBrowser() throws ParsingException, ConfigurationException, ProcessingException { + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(true) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "") + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE) + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.execute((client, context, strategy) -> { + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + CloseableHttpResponse response = client.execute(post, HttpClientContext.create()); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @Test + public void testFrontchannelLogoutWithRedirectUrlDifferentBrowser() throws ParsingException, ConfigurationException, ProcessingException { + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(true) + .removeAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, "http://url") + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + samlClient.execute((client, context, strategy) -> { + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + CloseableHttpResponse response = client.execute(post, HttpClientContext.create()); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @Test + public void testLogoutWithPostBindingUnsetRedirectBindingSet() throws ParsingException, ConfigurationException, ProcessingException { + // https://issues.jboss.org/browse/KEYCLOAK-4779 + adminClient.realm(REALM_NAME) + .clients().get(sales2Rep.getId()) + .update(ClientBuilder.edit(sales2Rep) + .frontchannelLogout(true) + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "") + .attribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, "http://url") + .build()); + + Document logoutDoc = prepareLogoutFromSalesAfterLoggingIntoTwoApps(); + + SAMLDocumentHolder resp = samlClient.getSamlResponse(REDIRECT, (client, context, strategy) -> { + strategy.setRedirectable(false); + HttpUriRequest post = POST.createSamlUnsignedRequest(getAuthServerSamlEndpoint(REALM_NAME), null, logoutDoc); + return client.execute(post, context); + }); + + // Expect logout request for sales-post2 + assertThat(resp.getSamlObject(), isSamlLogoutRequest("http://url")); + Document logoutRespDoc = new SAML2LogoutResponseBuilder() + .destination(getAuthServerSamlEndpoint(REALM_NAME).toString()) + .issuer(SAML_CLIENT_ID_SALES_POST2) + .logoutRequestID(((LogoutRequestType) resp.getSamlObject()).getID()) + .buildDocument(); + + // Emulate successful logout response from sales-post2 logout + resp = samlClient.getSamlResponse(POST, (client, context, strategy) -> { + strategy.setRedirectable(false); + HttpUriRequest post = POST.createSamlUnsignedResponse(getAuthServerSamlEndpoint(REALM_NAME), null, logoutRespDoc); + return client.execute(post, context); + }); + + // Expect final successful logout response from auth server signalling final successful logout + assertThat(resp.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java index df49edb308..b4f313008c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java @@ -91,6 +91,11 @@ public class ClientBuilder { return this; } + public ClientBuilder frontchannelLogout(Boolean frontchannelLogout) { + rep.setFrontchannelLogout(frontchannelLogout); + return this; + } + public ClientBuilder secret(String secret) { rep.setSecret(secret); return this; @@ -115,6 +120,15 @@ public class ClientBuilder { return this; } + public ClientBuilder removeAttribute(String name) { + Map attributes = rep.getAttributes(); + if (attributes != null) { + attributes.remove(name); + rep.setAttributes(attributes); + } + return this; + } + public ClientBuilder authenticatorType(String providerId) { rep.setClientAuthenticatorType(providerId); return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/Matchers.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/Matchers.java index f88cfdfd84..0ab3a7bc43 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/Matchers.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/Matchers.java @@ -16,12 +16,20 @@ */ package org.keycloak.testsuite.util; +import org.keycloak.dom.saml.v2.SAML2Object; +import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.dom.saml.v2.protocol.StatusResponseType; +import org.keycloak.testsuite.util.matchers.SamlResponseTypeMatcher; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.testsuite.util.matchers.*; +import java.net.URI; import java.util.Map; import javax.ws.rs.core.Response; import org.apache.http.HttpResponse; import org.hamcrest.Matcher; +import static org.hamcrest.Matchers.*; /** * Additional hamcrest matchers for use in {@link org.junit.Assert#assertThat}. @@ -109,4 +117,40 @@ public class Matchers { public static Matcher header(Matcher> matcher) { return new ResponseHeaderMatcher(matcher); } + + /** + * Matches when the SAML status code of a {@link ResponseType} instance is equal to the given code. + * @param expectedStatusCode + * @return + */ + public static Matcher isSamlResponse(JBossSAMLURIConstants expectedStatus) { + return allOf( + instanceOf(ResponseType.class), + new SamlResponseTypeMatcher(is(URI.create(expectedStatus.get()))) + ); + } + + /** + * Matches when the destination of a SAML {@link LogoutRequestType} instance is equal to the given destination. + * @param expectedStatusCode + * @return + */ + public static Matcher isSamlLogoutRequest(String destination) { + return allOf( + instanceOf(LogoutRequestType.class), + new SamlLogoutRequestTypeMatcher(URI.create(destination)) + ); + } + + /** + * Matches when the SAML status of a {@link StatusResponseType} instance is equal to the given code. + * @param expectedStatusCode + * @return + */ + public static Matcher isSamlStatusResponse(JBossSAMLURIConstants expectedStatus) { + return allOf( + instanceOf(StatusResponseType.class), + new SamlStatusResponseTypeMatcher(is(URI.create(expectedStatus.get()))) + ); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/SamlClient.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/SamlClient.java index ccaca6c319..5d5675f2bf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/SamlClient.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/SamlClient.java @@ -58,11 +58,11 @@ import java.util.List; import java.util.Objects; import java.util.UUID; +import org.apache.http.protocol.HttpContext; import static org.hamcrest.Matchers.*; import static org.junit.Assert.assertThat; import static org.keycloak.testsuite.admin.Users.getPasswordOf; import static org.keycloak.testsuite.arquillian.AuthServerTestEnricher.getAuthServerContextRoot; -import static org.keycloak.testsuite.util.IOUtil.documentToString; import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC; /** @@ -221,9 +221,11 @@ public class SamlClient { public static SAMLDocumentHolder extractSamlResponseFromForm(String responsePage) { org.jsoup.nodes.Document theResponsePage = Jsoup.parse(responsePage); Elements samlResponses = theResponsePage.select("input[name=SAMLResponse]"); - assertThat("Checking uniqueness of SAMLResponse input field in the page", samlResponses, hasSize(1)); + Elements samlRequests = theResponsePage.select("input[name=SAMLRequest]"); + int size = samlResponses.size() + samlRequests.size(); + assertThat("Checking uniqueness of SAMLResponse/SAMLRequest input field in the page", size, is(1)); - Element respElement = samlResponses.first(); + Element respElement = samlResponses.isEmpty() ? samlRequests.first() : samlResponses.first(); return SAMLRequestParser.parseResponsePostBinding(respElement.val()); } @@ -237,15 +239,15 @@ public class SamlClient { public static SAMLDocumentHolder extractSamlResponseFromRedirect(String responseUri) { List params = URLEncodedUtils.parse(URI.create(responseUri), "UTF-8"); - String samlResponse = null; + String samlDoc = null; for (NameValuePair param : params) { - if ("SAMLResponse".equals(param.getName())) { - assertThat(samlResponse, nullValue()); - samlResponse = param.getValue(); + if ("SAMLResponse".equals(param.getName()) || "SAMLRequest".equals(param.getName())) { + assertThat("Only one SAMLRequest/SAMLResponse check", samlDoc, nullValue()); + samlDoc = param.getValue(); } } - return SAMLRequestParser.parseResponseRedirectBinding(samlResponse); + return SAMLRequestParser.parseResponseRedirectBinding(samlDoc); } /** @@ -386,23 +388,14 @@ public class SamlClient { */ public static SAMLDocumentHolder login(UserRepresentation user, URI samlEndpoint, Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding) { - return login(user, samlEndpoint, samlRequest, relayState, requestBinding, expectedResponseBinding, false, true); + return new SamlClient(samlEndpoint).login(user, samlRequest, relayState, requestBinding, expectedResponseBinding, false, true); } - /** - * Send request for login form and then login using user param. This method is designed for clients which requires consent - * - * @param user - * @param samlEndpoint - * @param samlRequest - * @param relayState - * @param requestBinding - * @param expectedResponseBinding - * @return - */ - public static SAMLDocumentHolder loginWithRequiredConsent(UserRepresentation user, URI samlEndpoint, - Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding, boolean consent) { - return login(user, samlEndpoint, samlRequest, relayState, requestBinding, expectedResponseBinding, true, consent); + private final HttpClientContext context = HttpClientContext.create(); + private final URI samlEndpoint; + + public SamlClient(URI samlEndpoint) { + this.samlEndpoint = samlEndpoint; } /** @@ -418,15 +411,11 @@ public class SamlClient { * @param consent * @return */ - public static SAMLDocumentHolder login(UserRepresentation user, URI samlEndpoint, - Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding, boolean consentRequired, boolean consent) { - CloseableHttpResponse response = null; - SamlClient.RedirectStrategyWithSwitchableFollowRedirect strategy = new SamlClient.RedirectStrategyWithSwitchableFollowRedirect(); - try (CloseableHttpClient client = HttpClientBuilder.create().setRedirectStrategy(strategy).build()) { - HttpClientContext context = HttpClientContext.create(); - + public SAMLDocumentHolder login(UserRepresentation user, + Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding, boolean consentRequired, boolean consent) { + return getSamlResponse(expectedResponseBinding, (client, context, strategy) -> { HttpUriRequest post = requestBinding.createSamlUnsignedRequest(samlEndpoint, relayState, samlRequest); - response = client.execute(post, context); + CloseableHttpResponse response = client.execute(post, context); assertThat(response, statusCodeIsHC(Response.Status.OK)); String loginPageText = EntityUtils.toString(response.getEntity(), "UTF-8"); @@ -444,8 +433,90 @@ public class SamlClient { } strategy.setRedirectable(false); - response = client.execute(loginRequest, context); - + return client.execute(loginRequest, context); + }); + } + + /** + * Send request for login form once already logged in, hence login using SSO. + * Check whether client requires consent and handle consent page. + * + * @param user + * @param samlEndpoint + * @param samlRequest + * @param relayState + * @param requestBinding + * @param expectedResponseBinding + * @return + */ + public SAMLDocumentHolder subsequentLoginViaSSO(Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding) { + return getSamlResponse(expectedResponseBinding, (client, context, strategy) -> { + strategy.setRedirectable(false); + + HttpUriRequest post = requestBinding.createSamlUnsignedRequest(samlEndpoint, relayState, samlRequest); + CloseableHttpResponse response = client.execute(post, context); + assertThat(response, statusCodeIsHC(Response.Status.FOUND)); + String location = response.getFirstHeader("Location").getValue(); + + response = client.execute(new HttpGet(location), context); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + /** + * Send request for login form once already logged in, hence login using SSO. + * Check whether client requires consent and handle consent page. + * + * @param user + * @param samlEndpoint + * @param samlRequest + * @param relayState + * @param requestBinding + * @param expectedResponseBinding + * @return + */ + public SAMLDocumentHolder logout(Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding) { + return getSamlResponse(expectedResponseBinding, (client, context, strategy) -> { + strategy.setRedirectable(false); + + HttpUriRequest post = requestBinding.createSamlUnsignedRequest(samlEndpoint, relayState, samlRequest); + CloseableHttpResponse response = client.execute(post, context); + assertThat(response, statusCodeIsHC(Response.Status.OK)); + return response; + }); + } + + @FunctionalInterface + public interface HttpClientProcessor { + public CloseableHttpResponse process(CloseableHttpClient client, HttpContext context, RedirectStrategyWithSwitchableFollowRedirect strategy) throws Exception; + } + + public void execute(HttpClientProcessor body) { + CloseableHttpResponse response = null; + RedirectStrategyWithSwitchableFollowRedirect strategy = new RedirectStrategyWithSwitchableFollowRedirect(); + + try (CloseableHttpClient client = HttpClientBuilder.create().setRedirectStrategy(strategy).build()) { + response = body.process(client, context, strategy); + } catch (Exception ex) { + throw new RuntimeException(ex); + } finally { + if (response != null) { + EntityUtils.consumeQuietly(response.getEntity()); + try { + response.close(); + } catch (IOException ex) { + } + } + } + } + + public SAMLDocumentHolder getSamlResponse(Binding expectedResponseBinding, HttpClientProcessor body) { + CloseableHttpResponse response = null; + RedirectStrategyWithSwitchableFollowRedirect strategy = new RedirectStrategyWithSwitchableFollowRedirect(); + try (CloseableHttpClient client = HttpClientBuilder.create().setRedirectStrategy(strategy).build()) { + response = body.process(client, context, strategy); + return expectedResponseBinding.extractResponse(response); } catch (Exception ex) { throw new RuntimeException(ex); @@ -469,7 +540,7 @@ public class SamlClient { * @return */ public static SAMLDocumentHolder idpInitiatedLogin(UserRepresentation user, URI idpInitiatedURI, Binding expectedResponseBinding) { - return idpInitiatedLogin(user, idpInitiatedURI, expectedResponseBinding, false, true); + return new SamlClient(idpInitiatedURI).idpInitiatedLogin(user, expectedResponseBinding, false, true); } /** @@ -482,29 +553,24 @@ public class SamlClient { * @return */ public static SAMLDocumentHolder idpInitiatedLoginWithRequiredConsent(UserRepresentation user, URI idpInitiatedURI, Binding expectedResponseBinding, boolean consent) { - return idpInitiatedLogin(user, idpInitiatedURI, expectedResponseBinding, true, consent); + return new SamlClient(idpInitiatedURI).idpInitiatedLogin(user, expectedResponseBinding, true, consent); } /** * Send request for login form and then login using user param. Checks whether client requires consent and handle consent page. * * @param user - * @param idpInitiatedURI + * @param samlEndpoint * @param expectedResponseBinding * @param consent * @return */ - public static SAMLDocumentHolder idpInitiatedLogin(UserRepresentation user, URI idpInitiatedURI, Binding expectedResponseBinding, boolean consentRequired, boolean consent) { - CloseableHttpResponse response = null; - SamlClient.RedirectStrategyWithSwitchableFollowRedirect strategy = new SamlClient.RedirectStrategyWithSwitchableFollowRedirect(); - try (CloseableHttpClient client = HttpClientBuilder.create().setRedirectStrategy(strategy).build()) { - - HttpGet get = new HttpGet(idpInitiatedURI); - response = client.execute(get); + public SAMLDocumentHolder idpInitiatedLogin(UserRepresentation user, Binding expectedResponseBinding, boolean consentRequired, boolean consent) { + return getSamlResponse(expectedResponseBinding, (client, context, strategy) -> { + HttpGet get = new HttpGet(samlEndpoint); + CloseableHttpResponse response = client.execute(get); assertThat(response, statusCodeIsHC(Response.Status.OK)); - HttpClientContext context = HttpClientContext.create(); - String loginPageText = EntityUtils.toString(response.getEntity(), "UTF-8"); response.close(); @@ -520,20 +586,8 @@ public class SamlClient { } strategy.setRedirectable(false); - response = client.execute(loginRequest, context); - - return expectedResponseBinding.extractResponse(response); - } catch (Exception ex) { - throw new RuntimeException(ex); - } finally { - if (response != null) { - EntityUtils.consumeQuietly(response.getEntity()); - try { - response.close(); - } catch (IOException ex) { - } - } - } + return client.execute(loginRequest, context); + }); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/HttpResponseStatusCodeMatcher.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/HttpResponseStatusCodeMatcher.java index 78d5b3f434..d7b7230451 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/HttpResponseStatusCodeMatcher.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/HttpResponseStatusCodeMatcher.java @@ -16,8 +16,10 @@ */ package org.keycloak.testsuite.util.matchers; +import java.io.IOException; import javax.ws.rs.core.Response; import org.apache.http.HttpResponse; +import org.apache.http.util.EntityUtils; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -39,6 +41,17 @@ public class HttpResponseStatusCodeMatcher extends BaseMatcher { return (item instanceof HttpResponse) && this.matcher.matches(((HttpResponse) item).getStatusLine().getStatusCode()); } + @Override + public void describeMismatch(Object item, Description description) { + Description d = description.appendText("was ").appendValue(item) + .appendText(" with entity "); + try { + d.appendText(EntityUtils.toString(((HttpResponse) item).getEntity())); + } catch (IOException e) { + d.appendText(""); + } + } + @Override public void describeTo(Description description) { description.appendText("response status code matches ").appendDescriptionOf(this.matcher); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlLogoutRequestTypeMatcher.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlLogoutRequestTypeMatcher.java new file mode 100644 index 0000000000..10f7359ebf --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlLogoutRequestTypeMatcher.java @@ -0,0 +1,44 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package org.keycloak.testsuite.util.matchers; + +import org.keycloak.dom.saml.v2.SAML2Object; +import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; +import java.net.URI; +import org.hamcrest.*; +import static org.hamcrest.Matchers.*; + +/** + * + * @author hmlnarik + */ +public class SamlLogoutRequestTypeMatcher extends BaseMatcher { + + private final Matcher destinationMatcher; + + public SamlLogoutRequestTypeMatcher(URI destination) { + this.destinationMatcher = is(destination); + } + + public SamlLogoutRequestTypeMatcher(Matcher destinationMatcher) { + this.destinationMatcher = destinationMatcher; + } + + @Override + public boolean matches(Object item) { + return destinationMatcher.matches(((LogoutRequestType) item).getDestination()); + } + + @Override + public void describeMismatch(Object item, Description description) { + description.appendText("was ").appendValue(((LogoutRequestType) item).getDestination()); + } + + @Override + public void describeTo(Description description) { + description.appendText("SAML logout request destination matches ").appendDescriptionOf(this.destinationMatcher); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlResponseTypeMatcher.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlResponseTypeMatcher.java new file mode 100644 index 0000000000..46eedde1af --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlResponseTypeMatcher.java @@ -0,0 +1,47 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package org.keycloak.testsuite.util.matchers; + +import org.keycloak.dom.saml.v2.SAML2Object; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import java.net.URI; +import org.hamcrest.*; +import static org.hamcrest.Matchers.*; + +/** + * + * @author hmlnarik + */ +public class SamlResponseTypeMatcher extends BaseMatcher { + + private final Matcher statusMatcher; + + public SamlResponseTypeMatcher(JBossSAMLURIConstants expectedStatus) { + this.statusMatcher = is(URI.create(expectedStatus.get())); + } + + public SamlResponseTypeMatcher(Matcher statusMatcher) { + this.statusMatcher = statusMatcher; + } + + @Override + public boolean matches(Object item) { + return statusMatcher.matches(((ResponseType) item).getStatus().getStatusCode().getValue()); + } + + @Override + public void describeMismatch(Object item, Description description) { + description.appendText("was ").appendValue(((ResponseType) item).getStatus().getStatusCode()); + } + + @Override + public void describeTo(Description description) { + description.appendText("SAML response status code matches ").appendDescriptionOf(this.statusMatcher); + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlStatusResponseTypeMatcher.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlStatusResponseTypeMatcher.java new file mode 100644 index 0000000000..ccd5377865 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/matchers/SamlStatusResponseTypeMatcher.java @@ -0,0 +1,45 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package org.keycloak.testsuite.util.matchers; + +import org.keycloak.dom.saml.v2.SAML2Object; +import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; +import org.keycloak.dom.saml.v2.protocol.StatusResponseType; +import java.net.URI; +import org.hamcrest.*; +import static org.hamcrest.Matchers.*; + +/** + * + * @author hmlnarik + */ +public class SamlStatusResponseTypeMatcher extends BaseMatcher { + + private final Matcher statusMatcher; + + public SamlStatusResponseTypeMatcher(URI statusMatcher) { + this.statusMatcher = is(statusMatcher); + } + + public SamlStatusResponseTypeMatcher(Matcher statusMatcher) { + this.statusMatcher = statusMatcher; + } + + @Override + public boolean matches(Object item) { + return statusMatcher.matches(((StatusResponseType) item).getStatus().getStatusCode().getValue()); + } + + @Override + public void describeMismatch(Object item, Description description) { + description.appendText("was ").appendValue(((StatusResponseType) item).getStatus().getStatusCode().getValue()); + } + + @Override + public void describeTo(Description description) { + description.appendText("SAML status response status matches ").appendDescriptionOf(this.statusMatcher); + } +}