From 3cd413dee13bab82e524ba859d250f254e9b27c1 Mon Sep 17 00:00:00 2001 From: lpa Date: Fri, 25 Nov 2022 15:27:20 +0100 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFSOAP=20backchannel=20logout=20for=20SA?= =?UTF-8?q?ML=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #16293 --- .../saml/BaseSAML2BindingBuilder.java | 26 ++- .../api/saml/v2/response/SAML2Response.java | 18 +- .../EntityDescriptorDescriptionConverter.java | 2 + .../protocol/saml/IDPMetadataDescriptor.java | 1 + .../saml/JaxrsSAML2BindingBuilder.java | 24 +- .../keycloak/protocol/saml/SamlProtocol.java | 73 ++++++- .../keycloak/protocol/saml/SamlService.java | 125 ++++++----- .../profile/ecp/SamlEcpProfileService.java | 12 + .../protocol/saml/profile/util/Soap.java | 17 +- .../keycloak/testsuite/util/SamlClient.java | 35 +-- .../testsuite/util/SamlClientBuilder.java | 11 +- .../saml/CreateLogoutRequestStepBuilder.java | 24 +- .../saml/SamlBackchannelLogoutReceiver.java | 124 +++++++++++ .../client/SAMLClientRegistrationTest.java | 4 +- .../keycloak/testsuite/saml/LogoutTest.java | 205 ++++++++++++++++-- .../testsuite/saml/SOAPBindingTest.java | 12 +- 16 files changed, 611 insertions(+), 102 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlBackchannelLogoutReceiver.java diff --git a/saml-core/src/main/java/org/keycloak/saml/BaseSAML2BindingBuilder.java b/saml-core/src/main/java/org/keycloak/saml/BaseSAML2BindingBuilder.java index 78dff91bd44..58203e477ea 100755 --- a/saml-core/src/main/java/org/keycloak/saml/BaseSAML2BindingBuilder.java +++ b/saml-core/src/main/java/org/keycloak/saml/BaseSAML2BindingBuilder.java @@ -212,6 +212,28 @@ public class BaseSAML2BindingBuilder { } } + public static class BaseSoapBindingBuilder { + protected Document document; + protected BaseSAML2BindingBuilder builder; + + public BaseSoapBindingBuilder(BaseSAML2BindingBuilder builder, Document document) throws ProcessingException { + this.builder = builder; + this.document = document; + if (builder.signAssertions) { + builder.signAssertion(document); + } + if (builder.encrypt) builder.encryptDocument(document); + if (builder.sign) { + builder.signDocument(document); + } + } + + public Document getDocument() { + return document; + } + + } + public BaseRedirectBindingBuilder redirectBinding(Document document) throws ProcessingException { return new BaseRedirectBindingBuilder(this, document); @@ -222,7 +244,9 @@ public class BaseSAML2BindingBuilder { } - + public BaseSoapBindingBuilder soapBinding(Document document) throws ProcessingException { + return new BaseSoapBindingBuilder(this, document); + } public String getSAMLNSPrefix(Document samlResponseDocument) { Node assertionElement = samlResponseDocument.getDocumentElement() diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java index e3d3bdeeede..1500512e03b 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java @@ -66,6 +66,7 @@ import javax.xml.datatype.XMLGregorianCalendar; import javax.xml.namespace.QName; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Writer; @@ -393,6 +394,21 @@ public class SAML2Response { } + /** + * Get the Underlying SAML2Object from a document + * @param samlDocument a Document containing a SAML2Object + * @return a SAMLDocumentHolder + * @throws ProcessingException + * @throws ParsingException + */ + public static SAMLDocumentHolder getSAML2ObjectFromDocument(Document samlDocument) throws ProcessingException, ParsingException { + SAMLParser samlParser = SAMLParser.getInstance(); + JAXPValidationUtil.checkSchemaValidation(samlDocument); + SAML2Object responseType = (SAML2Object) samlParser.parse(samlDocument); + + return new SAMLDocumentHolder(responseType, samlDocument); + } + /** * Convert an EncryptedElement into a Document * @@ -423,7 +439,7 @@ public class SAML2Response { * @throws ConfigurationException * @throws ProcessingException */ - public Document convert(StatusResponseType responseType) throws ProcessingException, ConfigurationException, + public static Document convert(StatusResponseType responseType) throws ProcessingException, ConfigurationException, ParsingException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); diff --git a/services/src/main/java/org/keycloak/protocol/saml/EntityDescriptorDescriptionConverter.java b/services/src/main/java/org/keycloak/protocol/saml/EntityDescriptorDescriptionConverter.java index 466ec0af801..7f3ffa3db8e 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/EntityDescriptorDescriptionConverter.java +++ b/services/src/main/java/org/keycloak/protocol/saml/EntityDescriptorDescriptionConverter.java @@ -194,6 +194,8 @@ public class EntityDescriptorDescriptionConverter implements ClientDescriptionCo if (logoutPost != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, logoutPost); String logoutRedirect = getLogoutLocation(spDescriptorType, JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get()); if (logoutRedirect != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, logoutRedirect); + String logoutSoap = getLogoutLocation(spDescriptorType, JBossSAMLURIConstants.SAML_SOAP_BINDING.get()); + if (logoutSoap != null) attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, logoutSoap); String assertionConsumerServicePostBinding = getServiceURL(spDescriptorType, JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get()); if (assertionConsumerServicePostBinding != null) { diff --git a/services/src/main/java/org/keycloak/protocol/saml/IDPMetadataDescriptor.java b/services/src/main/java/org/keycloak/protocol/saml/IDPMetadataDescriptor.java index 8e42e3abbea..410de32d98f 100644 --- a/services/src/main/java/org/keycloak/protocol/saml/IDPMetadataDescriptor.java +++ b/services/src/main/java/org/keycloak/protocol/saml/IDPMetadataDescriptor.java @@ -75,6 +75,7 @@ public class IDPMetadataDescriptor { spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_POST_BINDING.getUri(), logoutEndpoint)); spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_REDIRECT_BINDING.getUri(), logoutEndpoint)); spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_HTTP_ARTIFACT_BINDING.getUri(), logoutEndpoint)); + spIDPDescriptor.addSingleLogoutService(new EndpointType(SAML_SOAP_BINDING.getUri(), logoutEndpoint)); spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_HTTP_POST_BINDING.getUri(), loginPostEndpoint)); spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_HTTP_REDIRECT_BINDING.getUri(), loginRedirectEndpoint)); spIDPDescriptor.addSingleSignOnService(new EndpointType(SAML_SOAP_BINDING.getUri(), loginPostEndpoint)); diff --git a/services/src/main/java/org/keycloak/protocol/saml/JaxrsSAML2BindingBuilder.java b/services/src/main/java/org/keycloak/protocol/saml/JaxrsSAML2BindingBuilder.java index 9e8d1ba73c6..d02dca5543c 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/JaxrsSAML2BindingBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/saml/JaxrsSAML2BindingBuilder.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.saml; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.saml.profile.util.Soap; import org.keycloak.saml.BaseSAML2BindingBuilder; import org.keycloak.saml.common.constants.GeneralConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; @@ -95,6 +96,22 @@ public class JaxrsSAML2BindingBuilder extends BaseSAML2BindingBuilder" : samlObject.getClass().getSimpleName())); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } RequestAbstractType requestAbstractType = (RequestAbstractType) samlObject; @@ -304,7 +306,7 @@ public class SamlService extends AuthorizationEndpointBase { } catch (VerificationException e) { SamlService.logger.error("request validation failed", e); event.error(Errors.INVALID_SIGNATURE); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER); } logger.debug("verified request"); @@ -312,7 +314,7 @@ public class SamlService extends AuthorizationEndpointBase { requestAbstractType.getDestination() == null && containsUnencryptedSignature(documentHolder)) { event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); event.error(Errors.INVALID_REQUEST); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } if (samlObject instanceof AuthnRequestType) { @@ -353,7 +355,7 @@ public class SamlService extends AuthorizationEndpointBase { event.event(EventType.LOGIN); event.detail(Details.REASON, e.getMessage()); event.error(Errors.INVALID_SAML_ARTIFACT); - asyncResponse.resume(ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST)); + asyncResponse.resume(error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST)); return; } @@ -393,7 +395,7 @@ public class SamlService extends AuthorizationEndpointBase { event.event(EventType.LOGIN); event.detail(Details.REASON, e.getMessage()); event.error(Errors.IDENTITY_PROVIDER_ERROR); - asyncResponse.resume(ErrorPage.error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST)); + asyncResponse.resume(error(session, null, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST)); return; } } @@ -412,7 +414,7 @@ public class SamlService extends AuthorizationEndpointBase { SamlClient samlClient = new SamlClient(client); if (! validateDestination(requestAbstractType, samlClient, Errors.INVALID_SAML_AUTHN_REQUEST)) { - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } String bindingType = getBindingType(requestAbstractType); @@ -441,7 +443,7 @@ public class SamlService extends AuthorizationEndpointBase { if (redirect == null) { event.error(Errors.INVALID_REDIRECT_URI); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REDIRECT_URI); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REDIRECT_URI); } AuthenticationSessionModel authSession = createAuthenticationSession(client, relayState); @@ -472,7 +474,7 @@ public class SamlService extends AuthorizationEndpointBase { } else { event.detail(Details.REASON, Errors.UNSUPPORTED_NAMEID_FORMAT); event.error(Errors.INVALID_SAML_AUTHN_REQUEST); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.UNSUPPORTED_NAME_ID_FORMAT); + return error(session, null, Response.Status.BAD_REQUEST, Messages.UNSUPPORTED_NAME_ID_FORMAT); } } @@ -532,7 +534,7 @@ public class SamlService extends AuthorizationEndpointBase { protected Response logoutRequest(LogoutRequestType logoutRequest, ClientModel client, String relayState) { SamlClient samlClient = new SamlClient(client); if (! validateDestination(logoutRequest, samlClient, Errors.INVALID_SAML_LOGOUT_REQUEST)) { - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); } // authenticate identity cookie, but ignore an access token timeout as we're logging out anyways. @@ -598,9 +600,18 @@ public class SamlService extends AuthorizationEndpointBase { } try { + event.event(EventType.LOGOUT) + .detail(Details.AUTH_METHOD, userSession.getAuthMethod()) + .client(session.getContext().getClient()) + .user(userSession.getUser()) + .session(userSession) + .detail(Details.USERNAME, userSession.getLoginUsername()) + .detail(Details.RESPONSE_MODE, getBindingType()); authManager.backchannelLogout(session, realm, userSession, session.getContext().getUri(), clientConnection, headers, true); + event.success(); } catch (Exception e) { logger.warn("Failure with backchannel logout", e); + event.error("Failure with backchannel logout"); } } @@ -628,6 +639,8 @@ public class SamlService extends AuthorizationEndpointBase { try { if (postBinding) { return binding.postBinding(builder.buildDocument()).response(logoutBindingUri); + } else if (SamlProtocol.SAML_SOAP_BINDING.equals(logoutBinding)) { + return binding.soapBinding(builder.buildDocument()).response(); } else { return binding.redirectBinding(builder.buildDocument()).response(logoutBindingUri); } @@ -704,10 +717,46 @@ public class SamlService extends AuthorizationEndpointBase { final URI baseUri = session.getContext().getUri().getBaseUri(); return Urls.samlRequestEndpoint(baseUri, realmName); } + + private Response checkClientValidity(ClientModel client) { + if (client == null) { + event.event(EventType.LOGIN); + event.detail(Details.REASON, "Cannot_match_source_hash"); + event.error(Errors.CLIENT_NOT_FOUND); + return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + if (!client.isEnabled()) { + event.event(EventType.LOGIN); + event.error(Errors.CLIENT_DISABLED); + return error(session, null, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED); + } + if (client.isBearerOnly()) { + event.event(EventType.LOGIN); + event.error(Errors.NOT_ALLOWED); + return error(session, null, Response.Status.BAD_REQUEST, Messages.BEARER_ONLY); + } + if (!client.isStandardFlowEnabled()) { + event.event(EventType.LOGIN); + event.error(Errors.NOT_ALLOWED); + return error(session, null, Response.Status.BAD_REQUEST, Messages.STANDARD_FLOW_DISABLED); + } + if (!isClientProtocolCorrect(client)) { + event.event(EventType.LOGIN); + event.error(Errors.INVALID_CLIENT); + return error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol."); + } + + return null; + } } protected class PostBindingProtocol extends BindingProtocol { + @Override + protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) { + return ErrorPage.error(session, authenticationSession, status, message, parameters); + } + @Override protected String encodeSamlDocument(Document samlDocument) throws ProcessingException { try { @@ -748,6 +797,11 @@ public class SamlService extends AuthorizationEndpointBase { protected class RedirectBindingProtocol extends BindingProtocol { + @Override + protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) { + return ErrorPage.error(session, authenticationSession, status, message, parameters); + } + @Override protected String encodeSamlDocument(Document samlDocument) throws ProcessingException { try { @@ -883,37 +937,6 @@ public class SamlService extends AuthorizationEndpointBase { return false; } - private Response checkClientValidity(ClientModel client) { - if (client == null) { - event.event(EventType.LOGIN); - event.detail(Details.REASON, "Cannot_match_source_hash"); - event.error(Errors.CLIENT_NOT_FOUND); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); - } - if (!client.isEnabled()) { - event.event(EventType.LOGIN); - event.error(Errors.CLIENT_DISABLED); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED); - } - if (client.isBearerOnly()) { - event.event(EventType.LOGIN); - event.error(Errors.NOT_ALLOWED); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.BEARER_ONLY); - } - if (!client.isStandardFlowEnabled()) { - event.event(EventType.LOGIN); - event.error(Errors.NOT_ALLOWED); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.STANDARD_FLOW_DISABLED); - } - if (!isClientProtocolCorrect(client)) { - event.event(EventType.LOGIN); - event.error(Errors.INVALID_CLIENT); - return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol."); - } - - return null; - } - @GET @Path("clients/{client}") @Produces(MediaType.TEXT_HTML_UTF_8) diff --git a/services/src/main/java/org/keycloak/protocol/saml/profile/ecp/SamlEcpProfileService.java b/services/src/main/java/org/keycloak/protocol/saml/profile/ecp/SamlEcpProfileService.java index a6c774c4978..76ad0dc3b53 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/profile/ecp/SamlEcpProfileService.java +++ b/services/src/main/java/org/keycloak/protocol/saml/profile/ecp/SamlEcpProfileService.java @@ -37,6 +37,7 @@ import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.validators.DestinationValidator; +import org.keycloak.services.ErrorPage; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.sessions.AuthenticationSessionModel; import org.w3c.dom.Document; @@ -68,11 +69,22 @@ public class SamlEcpProfileService extends SamlService { public Response authenticate(Document soapMessage) { try { return new PostBindingProtocol() { + + @Override + protected Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters) { + return Soap.createFault().code("error").reason(message).build(); + } + @Override protected String getBindingType(AuthnRequestType requestAbstractType) { return SamlProtocol.SAML_SOAP_BINDING; } + @Override + protected String getBindingType() { + return SamlProtocol.SAML_SOAP_BINDING; + } + @Override protected boolean isDestinationRequired() { return false; diff --git a/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java b/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java index 4d2eb86d8fc..ed6dab516fe 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java +++ b/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java @@ -23,6 +23,7 @@ import org.apache.http.entity.ContentType; import org.keycloak.saml.processing.core.saml.v2.util.DocumentUtil; import org.keycloak.saml.processing.web.util.PostBindingUtil; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.w3c.dom.Node; import javax.ws.rs.core.MediaType; @@ -101,7 +102,7 @@ public final class Soap { public static Document extractSoapMessage(SOAPMessage soapMessage) { try { SOAPBody soapBody = soapMessage.getSOAPBody(); - Node authnRequestNode = soapBody.getFirstChild(); + Node authnRequestNode = getFirstChild(soapBody); Document document = DocumentUtil.createDocument(); document.appendChild(document.importNode(authnRequestNode, true)); return document; @@ -110,6 +111,20 @@ public final class Soap { } } + /** + * Get the first direct child that is an XML element. + * In case of pretty-printed XML (with newlines and spaces), this method skips non-element objects (e.g. text) + * to really fetch the next XML tag. + */ + public static Node getFirstChild(Node parent) { + Node n = parent.getFirstChild(); + while (n != null && !(n instanceof Element)) { + n = n.getNextSibling(); + } + if (n == null) return null; + return n; + } + public static class SoapMessageBuilder { private final SOAPMessage message; private final SOAPBody body; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClient.java index 83ff815ecc1..829a00d6a4b 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClient.java @@ -75,6 +75,7 @@ import javax.xml.soap.SOAPException; import javax.xml.soap.SOAPHeader; import javax.xml.soap.SOAPHeaderElement; import javax.xml.soap.SOAPMessage; +import javax.xml.ws.soap.SOAPFaultException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -348,23 +349,31 @@ public class SamlClient { @Override public SAMLDocumentHolder extractResponse(CloseableHttpResponse response, String realmPublicKey) throws IOException { - assertThat(response, statusCodeIsHC(200)); - - MessageFactory messageFactory = null; try { - messageFactory = MessageFactory.newInstance(); - SOAPMessage soapMessage = messageFactory.createMessage(null, response.getEntity().getContent()); - SOAPBody soapBody = soapMessage.getSOAPBody(); - Node authnRequestNode = soapBody.getFirstChild(); - Document document = DocumentUtil.createDocument(); - document.appendChild(document.importNode(authnRequestNode, true)); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == 200) { + MessageFactory messageFactory = MessageFactory.newInstance(); + SOAPMessage soapMessage = messageFactory.createMessage(null, response.getEntity().getContent()); + SOAPBody soapBody = soapMessage.getSOAPBody(); + Node authnRequestNode = soapBody.getFirstChild(); + Document document = DocumentUtil.createDocument(); + document.appendChild(document.importNode(authnRequestNode, true)); - SAMLParser samlParser = SAMLParser.getInstance(); - JAXPValidationUtil.checkSchemaValidation(document); + SAMLParser samlParser = SAMLParser.getInstance(); + JAXPValidationUtil.checkSchemaValidation(document); - SAML2Object responseType = (SAML2Object) samlParser.parse(document); + SAML2Object responseType = (SAML2Object) samlParser.parse(document); - return new SAMLDocumentHolder(responseType, document); + return new SAMLDocumentHolder(responseType, document); + + } else if (statusCode == 500) { + MessageFactory messageFactory = MessageFactory.newInstance(); + SOAPMessage soapMessage = messageFactory.createMessage(null, response.getEntity().getContent()); + SOAPBody soapBody = soapMessage.getSOAPBody(); + throw new SOAPFaultException(soapBody.getFault()); + } else { + throw new RuntimeException("Unexpected response status code (" + statusCode + ")"); + } } catch (SOAPException | ConfigurationException | ProcessingException | ParsingException e) { throw new RuntimeException(e); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java index 397ad5f52d7..44a55e65f91 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java @@ -171,9 +171,14 @@ public class SamlClientBuilder { return addStepBuilder(new CreateAuthnRequestStepBuilder(authServerSamlUrl, authnRequestDocument, requestBinding, this)); } - /** Issues the given AuthnRequest to the SAML endpoint */ - public CreateLogoutRequestStepBuilder logoutRequest(URI authServerSamlUrl, String issuer, Binding requestBinding) { - return addStepBuilder(new CreateLogoutRequestStepBuilder(authServerSamlUrl, issuer, requestBinding, this)); + /** Issues the given LogoutRequest to the SAML endpoint */ + public CreateLogoutRequestStepBuilder logoutRequest(URI logoutServerSamlUrl, String issuer, Binding requestBinding) { + return addStepBuilder(new CreateLogoutRequestStepBuilder(logoutServerSamlUrl, issuer, requestBinding, this)); + } + + /** Issues the given LogoutRequest to the SAML endpoint */ + public CreateLogoutRequestStepBuilder logoutRequest(URI logoutServerSamlUrl, String issuer, Binding requestBinding, boolean skipSignature) { + return addStepBuilder(new CreateLogoutRequestStepBuilder(logoutServerSamlUrl, issuer, requestBinding, this, skipSignature)); } /** Issues the given SAML document to the SAML endpoint */ diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateLogoutRequestStepBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateLogoutRequestStepBuilder.java index 7d8daebab4f..609a6e2c38d 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateLogoutRequestStepBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateLogoutRequestStepBuilder.java @@ -35,7 +35,7 @@ import org.apache.http.impl.client.CloseableHttpClient; */ public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder { - private final URI authServerSamlUrl; + private final URI logoutServerSamlUrl; private final String issuer; private final Binding requestBinding; @@ -46,13 +46,23 @@ public class CreateLogoutRequestStepBuilder extends SamlDocumentStepBuilder { - assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); - return null; // Do not follow the redirect to the app from the returned response - }).build(); + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, POST).build() + .login().user(bburkeUser).build() + .processSamlResponse(POST) + .transformObject(this::extractNameIdAndSessionIndexAndTerminate) + .build() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST2, SAML_ASSERTION_CONSUMER_URL_SALES_POST2, POST).build() + .login().sso(true).build() // This is a formal step + .processSamlResponse(POST).transformObject(so -> { + assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + return null; // Do not follow the redirect to the app from the returned response + }).build(); + } + + private SamlClientBuilder prepareLogIntoTwoAppsSig() { + return new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, POST).build() + .login().user(bburkeUser).build() + .processSamlResponse(POST) + .transformObject(this::extractNameIdAndSessionIndexAndTerminate) + .build() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, POST) + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY).build() + .login().sso(true).build() // This is a formal step + .processSamlResponse(POST).transformObject(so -> { + assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + return null; // Do not follow the redirect to the app from the returned response + }).build(); } @Test @@ -195,6 +215,139 @@ public class LogoutTest extends AbstractSamlTest { assertLogoutEvent(SAML_CLIENT_ID_SALES_POST); } + /** + * Logout triggered with POST binding, with 2 clients to logout in the SLO process. + * One of the client is configured with backchannel logout + SOAP logout URL + */ + @Test + public void testSoapBackchannelLogout() { + try (SamlBackchannelLogoutReceiver backchannelLogoutReceiver = new SamlBackchannelLogoutReceiver(8082, sales2Rep); + Closeable sales2 = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST2) + .setFrontchannelLogout(false) + .setAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, backchannelLogoutReceiver.getUrl()) + .setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true") // sign logout requests + .setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username + .setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username + .update(); + ) { + + SAMLDocumentHolder samlResponse = prepareLogIntoTwoApps() + .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, POST) + .nameId(nameIdRef::get) + .sessionIndex(sessionIndexRef::get) + .build() + .getSamlResponse(POST); + + assertThat(samlResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + assertLogoutEvent(SAML_CLIENT_ID_SALES_POST); + + // check that the logout request sent to the client is compliant and signed + assertTrue(backchannelLogoutReceiver.isLogoutRequestReceived()); + LogoutRequestType logoutRequest = backchannelLogoutReceiver.getLogoutRequest(); + assertNotNull(backchannelLogoutReceiver.getLogoutRequest().getSignature()); + // check nameID + assertEquals(logoutRequest.getNameID().getValue(), bburkeUser.getUsername()); + } catch (Exception ex) { + fail("unexpected error"); + } + } + + /** + * Logout triggered with POST binding, with 2 clients to logout in the SLO process. + * One of the client is configured with backchannel logout + SOAP logout URL + * This client is also configured with "client signature required" --> a signature is expected on the logout response + */ + @Test + public void testSoapBackchannelLogoutSignedResponseFromClient() { + try (SamlBackchannelLogoutReceiver backchannelLogoutReceiver = new SamlBackchannelLogoutReceiver(8082, salesSigRep, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY, SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY); + Closeable salesSig = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG) + .setFrontchannelLogout(false) + .setAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, backchannelLogoutReceiver.getUrl()) + .setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true") // sign logout requests + .setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username + .setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username + .update(); + ) { + + SAMLDocumentHolder samlResponse = prepareLogIntoTwoAppsSig() + .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, POST) + .nameId(nameIdRef::get) + .sessionIndex(sessionIndexRef::get) + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .getSamlResponse(POST); + + assertThat(samlResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + assertLogoutEvent(SAML_CLIENT_ID_SALES_POST); + } catch (Exception ex) { + fail("unexpected error"); + } + } + + /** Logout triggered with SOAP binding, request is properly signed */ + @Test + public void testSoapBackchannelLogoutFromSamlClient() { + try ( + Closeable sales = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG) + .setFrontchannelLogout(false) + .setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username + .setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username + .update(); + ) { + + SAMLDocumentHolder samlLogoutResponse = prepareLogIntoTwoAppsSig() + .clearCookies() // remove cookies, since SOAP calls do not embed cookie normally + .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SOAP) + .nameId(nameIdRef::get) + .sessionIndex(sessionIndexRef::get) + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .getSamlResponse(SOAP); + + assertThat(samlLogoutResponse.getSamlObject(), isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + assertSoapLogoutEvent(SAML_CLIENT_ID_SALES_POST_SIG); + + } catch (Exception ex) { + ex.printStackTrace(); + fail("unexpected error"); + } + } + + /** Logout triggered with SOAP binding, request is wrongly not signed --> ensure an error is thrown */ + @Test + public void testSoapBackchannelLogoutFromSamlClientUnsignedRequest() { + try ( + Closeable sales = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG) + .setFrontchannelLogout(false) + .setAttribute(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true") // Force NameID to username + .setAttribute(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username") // Force NameID to username + .update(); + ) { + + try { + SAMLDocumentHolder samlLogoutResponse = prepareLogIntoTwoAppsSig() + .clearCookies() // remove cookies, since SOAP calls do not embed cookie normally + .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, SOAP, true) + .nameId(nameIdRef::get) + .sessionIndex(sessionIndexRef::get) + .build() + .getSamlResponse(SOAP); + fail("should have triggered an error"); + } catch (RuntimeException ex) { + // exception expected since the request is not signed + if (ex.getCause() instanceof SOAPFaultException) { + SOAPFaultException sfe = (SOAPFaultException) ex.getCause(); + assertThat(sfe.getFault().getFaultString(), is("invalidRequesterMessage")); + } + } + assertSoapLogoutErrorEvent(SAML_CLIENT_ID_SALES_POST_SIG); + + } catch (Exception ex) { + ex.printStackTrace(); + fail("unexpected error"); + } + } + @Test public void testFrontchannelLogoutNoLogoutServiceUrlSetInSameBrowser() { adminClient.realm(REALM_NAME) @@ -327,6 +480,28 @@ public class LogoutTest extends AbstractSamlTest { assertNotNull(logoutEvent.getDetails().get(SamlProtocol.SAML_LOGOUT_REQUEST_ID)); } + private void assertSoapLogoutEvent(String clientId) { + List logoutEvents = adminClient.realm(REALM_NAME) + .getEvents(Arrays.asList(EventType.LOGOUT.name()), clientId, null, null, null, null, null, null); + + assertFalse(logoutEvents.isEmpty()); + assertEquals(1, logoutEvents.size()); + + EventRepresentation logoutEvent = logoutEvents.get(0); + + assertEquals(bburkeUser.getUsername(), logoutEvent.getDetails().get(Details.USERNAME)); + assertEquals(SamlProtocol.SAML_SOAP_BINDING, logoutEvent.getDetails().get(Details.RESPONSE_MODE)); + assertEquals("saml", logoutEvent.getDetails().get(Details.AUTH_METHOD)); + } + + private void assertSoapLogoutErrorEvent(String clientId) { + List logoutEvents = adminClient.realm(REALM_NAME) + .getEvents(Arrays.asList(EventType.LOGOUT_ERROR.name()), null, null, null, null, null, null, null); + + assertFalse(logoutEvents.isEmpty()); + assertEquals(1, logoutEvents.size()); + } + private IdentityProviderRepresentation addIdentityProvider() { IdentityProviderRepresentation identityProvider = IdentityProviderBuilder.create() .providerId(SAMLIdentityProviderFactory.PROVIDER_ID) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SOAPBindingTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SOAPBindingTest.java index 27e6700e408..4fae8b9db78 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SOAPBindingTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SOAPBindingTest.java @@ -137,12 +137,13 @@ public class SOAPBindingTest extends AbstractSamlTest { .processSamlResponse(POST) .transformObject(this::extractNameIdAndSessionIndexAndTerminate) .build() + .clearCookies() .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP) .nameId(nameIdRef::get) .sessionIndex(sessionIndexRef::get) .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) .build() - .executeAndTransform(POST::extractResponse); + .executeAndTransform(SOAP::extractResponse); assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class)); @@ -164,11 +165,12 @@ public class SOAPBindingTest extends AbstractSamlTest { .processSamlResponse(POST) .transformObject(this::extractNameIdAndSessionIndexAndTerminate) .build() + .clearCookies() .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP) .nameId(nameIdRef::get) .sessionIndex(sessionIndexRef::get) .build() - .executeAndTransform(POST::extractResponse); + .executeAndTransform(SOAP::extractResponse); assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class)); @@ -184,6 +186,7 @@ public class SOAPBindingTest extends AbstractSamlTest { .processSamlResponse(POST) .transformObject(this::extractNameIdAndSessionIndexAndTerminate) .build() + .clearCookies() .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP) .nameId(nameIdRef::get) .sessionIndex(sessionIndexRef::get) @@ -193,7 +196,7 @@ public class SOAPBindingTest extends AbstractSamlTest { return logoutRequestType; }) .build() - .executeAndTransform(POST::extractResponse); + .executeAndTransform(SOAP::extractResponse); assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class)); @@ -215,6 +218,7 @@ public class SOAPBindingTest extends AbstractSamlTest { .processSamlResponse(POST) .transformObject(this::extractNameIdAndSessionIndexAndTerminate) .build() + .clearCookies() .logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP) .nameId(nameIdRef::get) .sessionIndex(sessionIndexRef::get) @@ -223,7 +227,7 @@ public class SOAPBindingTest extends AbstractSamlTest { return logoutRequestType; }) .build() - .executeAndTransform(POST::extractResponse); + .executeAndTransform(SOAP::extractResponse); assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));