KEYCLOAK-16592 Do not require destination with SOAP binding

This commit is contained in:
Michal Hajas 2021-02-16 15:03:52 +01:00 committed by Hynek Mlnařík
parent 852593310f
commit fc29a39e5a
11 changed files with 538 additions and 31 deletions

View file

@ -240,6 +240,10 @@ public class SAMLEndpoint {
protected abstract SAMLDocumentHolder extractRequestDocument(String samlRequest); protected abstract SAMLDocumentHolder extractRequestDocument(String samlRequest);
protected abstract SAMLDocumentHolder extractResponseDocument(String response); protected abstract SAMLDocumentHolder extractResponseDocument(String response);
protected boolean isDestinationRequired() {
return true;
}
protected KeyLocator getIDPKeyLocator() { protected KeyLocator getIDPKeyLocator() {
List<Key> keys = new LinkedList<>(); List<Key> keys = new LinkedList<>();
@ -271,7 +275,8 @@ public class SAMLEndpoint {
SAMLDocumentHolder holder = extractRequestDocument(samlRequest); SAMLDocumentHolder holder = extractRequestDocument(samlRequest);
RequestAbstractType requestAbstractType = (RequestAbstractType) holder.getSamlObject(); RequestAbstractType requestAbstractType = (RequestAbstractType) holder.getSamlObject();
// validate destination // validate destination
if (requestAbstractType.getDestination() == null && containsUnencryptedSignature(holder)) { if (isDestinationRequired() &&
requestAbstractType.getDestination() == null && containsUnencryptedSignature(holder)) {
event.event(EventType.IDENTITY_PROVIDER_RESPONSE); event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_REQUEST); event.error(Errors.INVALID_REQUEST);
@ -582,7 +587,8 @@ public class SAMLEndpoint {
} }
StatusResponseType statusResponse = (StatusResponseType)holder.getSamlObject(); StatusResponseType statusResponse = (StatusResponseType)holder.getSamlObject();
// validate destination // validate destination
if (statusResponse.getDestination() == null && containsUnencryptedSignature(holder)) { if (isDestinationRequired()
&& statusResponse.getDestination() == null && containsUnencryptedSignature(holder)) {
event.event(EventType.IDENTITY_PROVIDER_RESPONSE); event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE); event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);

View file

@ -142,6 +142,10 @@ public class SamlService extends AuthorizationEndpointBase {
return null; return null;
} }
protected boolean isDestinationRequired() {
return true;
}
protected Response handleSamlResponse(String samlResponse, String relayState) { protected Response handleSamlResponse(String samlResponse, String relayState) {
event.event(EventType.LOGOUT); event.event(EventType.LOGOUT);
SAMLDocumentHolder holder = extractResponseDocument(samlResponse); SAMLDocumentHolder holder = extractResponseDocument(samlResponse);
@ -154,7 +158,8 @@ public class SamlService extends AuthorizationEndpointBase {
StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject(); StatusResponseType statusResponse = (StatusResponseType) holder.getSamlObject();
// validate destination // validate destination
if (statusResponse.getDestination() == null && containsUnencryptedSignature(holder)) { if (isDestinationRequired() &&
statusResponse.getDestination() == null && containsUnencryptedSignature(holder)) {
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE); event.error(Errors.INVALID_SAML_LOGOUT_RESPONSE);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
@ -268,7 +273,8 @@ public class SamlService extends AuthorizationEndpointBase {
} }
logger.debug("verified request"); logger.debug("verified request");
if (requestAbstractType.getDestination() == null && containsUnencryptedSignature(documentHolder)) { if (isDestinationRequired() &&
requestAbstractType.getDestination() == null && containsUnencryptedSignature(documentHolder)) {
event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION);
event.error(Errors.INVALID_REQUEST); event.error(Errors.INVALID_REQUEST);
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
@ -501,6 +507,9 @@ public class SamlService extends AuthorizationEndpointBase {
} }
private boolean validateDestination(RequestAbstractType req, SamlClient samlClient, String errorCode) { private boolean validateDestination(RequestAbstractType req, SamlClient samlClient, String errorCode) {
if (!isDestinationRequired() && req.getDestination() == null) {
return true;
}
// validate destination // validate destination
if (req.getDestination() == null && samlClient.requiresClientSignature()) { if (req.getDestination() == null && samlClient.requiresClientSignature()) {
event.detail(Details.REASON, "missing_destination_required"); event.detail(Details.REASON, "missing_destination_required");

View file

@ -68,6 +68,11 @@ public class SamlEcpProfileService extends SamlService {
return SamlProtocol.SAML_SOAP_BINDING; return SamlProtocol.SAML_SOAP_BINDING;
} }
@Override
protected boolean isDestinationRequired() {
return false;
}
@Override @Override
protected Response loginRequest(String relayState, AuthnRequestType requestAbstractType, ClientModel client) { protected Response loginRequest(String relayState, AuthnRequestType requestAbstractType, ClientModel client) {
// force passive authentication when executing this profile // force passive authentication when executing this profile

View file

@ -24,6 +24,8 @@ import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URLEncodedUtils; import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.LaxRedirectStrategy; import org.apache.http.impl.client.LaxRedirectStrategy;
@ -32,20 +34,28 @@ import org.apache.http.util.EntityUtils;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.keycloak.adapters.saml.SamlDeployment;
import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.KeyUtils;
import org.keycloak.dom.saml.v2.SAML2Object;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.saml.BaseSAML2BindingBuilder; import org.keycloak.saml.BaseSAML2BindingBuilder;
import org.keycloak.saml.SAMLRequestParser; import org.keycloak.saml.SAMLRequestParser;
import org.keycloak.saml.SignatureAlgorithm; import org.keycloak.saml.SignatureAlgorithm;
import org.keycloak.saml.common.constants.GeneralConstants; import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.saml.common.constants.JBossSAMLConstants;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.exceptions.ConfigurationException; 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.common.exceptions.ProcessingException;
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.saml.v2.util.DocumentUtil;
import org.keycloak.saml.processing.core.util.JAXPValidationUtil;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URI; import java.net.URI;
@ -60,6 +70,13 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.xml.soap.MessageFactory;
import javax.xml.soap.SOAPBody;
import javax.xml.soap.SOAPEnvelope;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPHeader;
import javax.xml.soap.SOAPHeaderElement;
import javax.xml.soap.SOAPMessage;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
@ -68,8 +85,11 @@ import static org.junit.Assert.assertTrue;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
import org.keycloak.protocol.saml.SamlProtocolUtils; import org.keycloak.protocol.saml.SamlProtocolUtils;
import org.keycloak.rotation.KeyLocator; import org.keycloak.rotation.KeyLocator;
import static org.keycloak.saml.common.constants.GeneralConstants.RELAY_STATE; import static org.keycloak.saml.common.constants.GeneralConstants.RELAY_STATE;
import org.keycloak.saml.processing.web.util.RedirectBindingUtil; import org.keycloak.saml.processing.web.util.RedirectBindingUtil;
import org.w3c.dom.Node;
import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC; import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC;
/** /**
@ -311,7 +331,167 @@ public class SamlClient {
throw new RuntimeException(ex); throw new RuntimeException(ex);
} }
} }
}; },
/**
* SOAP binding is currently usable only with http://localhost:8280/ecp-sp/ client, see to-do comment within
* {@link #createSamlSignedRequest} for more details. After resolving that to-do it should be usable with any
* client.
*/
SOAP {
@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));
SAMLParser samlParser = SAMLParser.getInstance();
JAXPValidationUtil.checkSchemaValidation(document);
SAML2Object responseType = (SAML2Object) samlParser.parse(document);
return new SAMLDocumentHolder(responseType, document);
} catch (SOAPException | ConfigurationException | ProcessingException | ParsingException e) {
throw new RuntimeException(e);
}
}
private static final String NS_PREFIX_PROFILE_ECP = "ecp";
private static final String NS_PREFIX_SAML_PROTOCOL = "samlp";
private static final String NS_PREFIX_SAML_ASSERTION = "saml";
private static final String NS_PREFIX_PAOS_BINDING = "paos";
private void createEcpRequestHeader(SOAPEnvelope envelope, SamlDeployment deployment) throws SOAPException {
SOAPHeader headers = envelope.getHeader();
SOAPHeaderElement ecpRequestHeader = headers.addHeaderElement(envelope.createQName(JBossSAMLConstants.REQUEST.get(), NS_PREFIX_PROFILE_ECP));
ecpRequestHeader.setMustUnderstand(true);
ecpRequestHeader.setActor("http://schemas.xmlsoap.org/soap/actor/next");
ecpRequestHeader.addAttribute(envelope.createName("ProviderName"), deployment.getEntityID());
ecpRequestHeader.addAttribute(envelope.createName("IsPassive"), "0");
ecpRequestHeader.addChildElement(envelope.createQName("Issuer", "saml")).setValue(deployment.getEntityID());
ecpRequestHeader.addChildElement(envelope.createQName("IDPList", "samlp"))
.addChildElement(envelope.createQName("IDPEntry", "samlp"))
.addAttribute(envelope.createName("ProviderID"), deployment.getIDP().getEntityID())
.addAttribute(envelope.createName("Name"), deployment.getIDP().getEntityID())
.addAttribute(envelope.createName("Loc"), deployment.getIDP().getSingleSignOnService().getRequestBindingUrl());
}
private void createPaosRequestHeader(SOAPEnvelope envelope, SamlDeployment deployment) throws SOAPException {
SOAPHeader headers = envelope.getHeader();
SOAPHeaderElement paosRequestHeader = headers.addHeaderElement(envelope.createQName(JBossSAMLConstants.REQUEST.get(), NS_PREFIX_PAOS_BINDING));
paosRequestHeader.setMustUnderstand(true);
paosRequestHeader.setActor("http://schemas.xmlsoap.org/soap/actor/next");
paosRequestHeader.addAttribute(envelope.createName("service"), JBossSAMLURIConstants.ECP_PROFILE.get());
paosRequestHeader.addAttribute(envelope.createName("responseConsumerURL"), getResponseConsumerUrl(deployment));
}
private String getResponseConsumerUrl(SamlDeployment deployment) {
return (deployment.getIDP() == null
|| deployment.getIDP().getSingleSignOnService() == null
|| deployment.getIDP().getSingleSignOnService().getAssertionConsumerServiceUrl() == null
) ? null
: deployment.getIDP().getSingleSignOnService().getAssertionConsumerServiceUrl().toString();
}
@Override
public HttpUriRequest createSamlUnsignedRequest(URI samlEndpoint, String relayState, Document samlRequest) {
return createSamlSignedRequest(samlEndpoint, relayState, samlRequest, null, null, null);
}
@Override
public HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey) {
return createSamlSignedRequest(samlEndpoint, relayState, samlRequest, realmPrivateKey, realmPublicKey, null);
}
@Override
public HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey, String certificateStr) {
BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder();
if (realmPrivateKey != null && realmPublicKey != null) {
PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(realmPrivateKey);
PublicKey publicKey = org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(realmPublicKey);
X509Certificate cert = org.keycloak.common.util.PemUtils.decodeCertificate(certificateStr);
binding
.signatureAlgorithm(SignatureAlgorithm.RSA_SHA256)
.signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, cert)
.signDocument();
try {
samlRequest = binding.postBinding(samlRequest).getDocument();
} catch (ProcessingException e) {
throw new RuntimeException(e);
}
}
MessageFactory messageFactory = null;
try {
messageFactory = MessageFactory.newInstance();
SOAPMessage message = messageFactory.createMessage();
SOAPEnvelope envelope = message.getSOAPPart().getEnvelope();
envelope.addNamespaceDeclaration(NS_PREFIX_SAML_ASSERTION, JBossSAMLURIConstants.ASSERTION_NSURI.get());
envelope.addNamespaceDeclaration(NS_PREFIX_SAML_PROTOCOL, JBossSAMLURIConstants.PROTOCOL_NSURI.get());
envelope.addNamespaceDeclaration(NS_PREFIX_PAOS_BINDING, JBossSAMLURIConstants.PAOS_BINDING.get());
envelope.addNamespaceDeclaration(NS_PREFIX_PROFILE_ECP, JBossSAMLURIConstants.ECP_PROFILE.get());
SamlDeployment deployment = SamlUtils.getSamlDeploymentForClient("ecp-sp"); // TODO: Make more general for any client, currently SOAP is usable only with http://localhost:8280/ecp-sp/ client
createPaosRequestHeader(envelope, deployment);
createEcpRequestHeader(envelope, deployment);
SOAPBody body = envelope.getBody();
body.addDocument(samlRequest);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
message.writeTo(outputStream);
HttpPost post = new HttpPost(samlEndpoint);
post.setEntity(new ByteArrayEntity(outputStream.toByteArray(), ContentType.TEXT_XML));
return post;
} catch (SOAPException | IOException | ParsingException e) {
throw new RuntimeException(e);
}
}
@Override
public URI getBindingUri() {
return null;
}
@Override
public HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayState, Document samlRequest) {
return null;
}
@Override
public HttpUriRequest createSamlSignedResponse(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey) {
return null;
}
@Override
public HttpUriRequest createSamlSignedResponse(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey, String certificateStr) {
return null;
}
@Override
public String extractRelayState(CloseableHttpResponse response) throws IOException {
return null;
}
}
;
public abstract SAMLDocumentHolder extractResponse(CloseableHttpResponse response, String realmPublicKey) throws IOException; public abstract SAMLDocumentHolder extractResponse(CloseableHttpResponse response, String realmPublicKey) throws IOException;

View file

@ -0,0 +1,35 @@
package org.keycloak.testsuite.util;
import org.keycloak.adapters.saml.SamlDeployment;
import org.keycloak.adapters.saml.config.parsers.DeploymentBuilder;
import org.keycloak.adapters.saml.config.parsers.ResourceLoader;
import org.keycloak.saml.common.exceptions.ParsingException;
import org.keycloak.testsuite.utils.arquillian.DeploymentArchiveProcessorUtils;
import org.keycloak.testsuite.utils.io.IOUtil;
import org.w3c.dom.Document;
import java.io.InputStream;
public class SamlUtils {
public static SamlDeployment getSamlDeploymentForClient(String client) throws ParsingException {
InputStream is = SamlUtils.class.getResourceAsStream("/adapter-test/keycloak-saml/" + client + "/WEB-INF/keycloak-saml.xml");
// InputStream -> Document
Document doc = IOUtil.loadXML(is);
// Modify saml deployment the same way as before deploying to real app server
DeploymentArchiveProcessorUtils.modifySAMLDocument(doc);
// Document -> InputStream
InputStream isProcessed = IOUtil.documentToInputStream(doc);
// InputStream -> SamlDeployment
ResourceLoader loader = new ResourceLoader() {
@Override
public InputStream getResourceAsStream(String resource) {
return getClass().getResourceAsStream("/adapter-test/keycloak-saml/" + client + resource);
}
};
return new DeploymentBuilder().build(isProcessed, loader);
}
}

View file

@ -16,6 +16,9 @@
*/ */
package org.keycloak.testsuite.util.saml; package org.keycloak.testsuite.util.saml;
import org.keycloak.common.util.Base64;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.Users;
import org.keycloak.testsuite.util.SamlClientBuilder; import org.keycloak.testsuite.util.SamlClientBuilder;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.saml.common.exceptions.ConfigurationException; import org.keycloak.saml.common.exceptions.ConfigurationException;
@ -34,6 +37,8 @@ import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import javax.ws.rs.core.HttpHeaders;
public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<AuthnRequestType, CreateAuthnRequestStepBuilder> { public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<AuthnRequestType, CreateAuthnRequestStepBuilder> {
@ -44,6 +49,7 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
private String signingPublicKeyPem; // TODO: should not be needed private String signingPublicKeyPem; // TODO: should not be needed
private String signingPrivateKeyPem; private String signingPrivateKeyPem;
private String signingCertificate; private String signingCertificate;
private String authorizationHeader;
private final Document forceLoginRequestDocument; private final Document forceLoginRequestDocument;
@ -91,6 +97,14 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
return this; return this;
} }
public CreateAuthnRequestStepBuilder basicAuthentication(UserRepresentation user) {
String username = user.getUsername();
String password = Users.getPasswordOf(user);
String pair = username + ":" + password;
this.authorizationHeader = "Basic " + Base64.encodeBytes(pair.getBytes());
return this;
}
@Override @Override
public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception { public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception {
Document doc = createLoginRequestDocument(); Document doc = createLoginRequestDocument();
@ -104,9 +118,16 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
Document samlDoc = DocumentUtil.getDocument(transformed); Document samlDoc = DocumentUtil.getDocument(transformed);
String relayState = this.relayState == null ? null : this.relayState.get(); String relayState = this.relayState == null ? null : this.relayState.get();
return this.signingPrivateKeyPem == null
HttpUriRequest request = this.signingPrivateKeyPem == null
? requestBinding.createSamlUnsignedRequest(authServerSamlUrl, relayState, samlDoc) ? requestBinding.createSamlUnsignedRequest(authServerSamlUrl, relayState, samlDoc)
: requestBinding.createSamlSignedRequest(authServerSamlUrl, relayState, samlDoc, signingPrivateKeyPem, signingPublicKeyPem, signingCertificate); : requestBinding.createSamlSignedRequest(authServerSamlUrl, relayState, samlDoc, signingPrivateKeyPem, signingPublicKeyPem, signingCertificate);
if (authorizationHeader != null) {
request.addHeader(HttpHeaders.AUTHORIZATION, authorizationHeader);
}
return request;
} }
protected Document createLoginRequestDocument() { protected Document createLoginRequestDocument() {

View file

@ -1,8 +1,14 @@
package org.keycloak.testsuite.saml; package org.keycloak.testsuite.saml;
import org.keycloak.dom.saml.v2.SAML2Object;
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.AuthnRequestType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractAuthTest; import org.keycloak.testsuite.AbstractAuthTest;
import org.keycloak.testsuite.util.SamlClient; import org.keycloak.testsuite.util.SamlClient;
@ -17,7 +23,11 @@ import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.junit.Assert.assertThat;
import static org.keycloak.testsuite.util.Matchers.isSamlResponse;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT; import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME; import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED; import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED;
@ -36,6 +46,9 @@ public abstract class AbstractSamlTest extends AbstractAuthTest {
public static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/sales-post/saml"; public static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/sales-post/saml";
public static final String SAML_CLIENT_ID_SALES_POST = "http://localhost:8280/sales-post/"; public static final String SAML_CLIENT_ID_SALES_POST = "http://localhost:8280/sales-post/";
public static final String SAML_CLIENT_ID_ECP_SP = "http://localhost:8280/ecp-sp/";
public static final String SAML_ASSERTION_CONSUMER_URL_ECP_SP = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/ecp-sp/saml";
public static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST2 = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/sales-post2/saml"; public static final String SAML_ASSERTION_CONSUMER_URL_SALES_POST2 = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/sales-post2/saml";
public static final String SAML_CLIENT_ID_SALES_POST2 = "http://localhost:8280/sales-post2/"; public static final String SAML_CLIENT_ID_SALES_POST2 = "http://localhost:8280/sales-post2/";
@ -73,6 +86,9 @@ public abstract class AbstractSamlTest extends AbstractAuthTest {
public static final String SAML_BROKER_ALIAS = "saml-broker"; public static final String SAML_BROKER_ALIAS = "saml-broker";
protected final AtomicReference<NameIDType> nameIdRef = new AtomicReference<>();
protected final AtomicReference<String> sessionIndexRef = new AtomicReference<>();
@Override @Override
public void addTestRealms(List<RealmRepresentation> testRealms) { public void addTestRealms(List<RealmRepresentation> testRealms) {
testRealms.add(loadRealm("/adapter-test/keycloak-saml/testsaml.json")); testRealms.add(loadRealm("/adapter-test/keycloak-saml/testsaml.json"));
@ -109,4 +125,20 @@ public abstract class AbstractSamlTest extends AbstractAuthTest {
protected URI getSamlBrokerUrl(String realmName) { protected URI getSamlBrokerUrl(String realmName) {
return URI.create(getAuthServerRealmBase(realmName).toString() + "/broker/" + SAML_BROKER_ALIAS + "/endpoint"); return URI.create(getAuthServerRealmBase(realmName).toString() + "/broker/" + SAML_BROKER_ALIAS + "/endpoint");
} }
protected SAML2Object extractNameIdAndSessionIndexAndTerminate(SAML2Object so) {
assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType loginResp1 = (ResponseType) so;
final AssertionType firstAssertion = loginResp1.getAssertions().get(0).getAssertion();
assertThat(firstAssertion, org.hamcrest.Matchers.notNullValue());
assertThat(firstAssertion.getSubject().getSubType().getBaseID(), instanceOf(NameIDType.class));
NameIDType nameId = (NameIDType) firstAssertion.getSubject().getSubType().getBaseID();
AuthnStatementType firstAssertionStatement = (AuthnStatementType) firstAssertion.getStatements().iterator().next();
nameIdRef.set(nameId);
sessionIndexRef.set(firstAssertionStatement.getSessionIndex());
return null;
}
} }

View file

@ -90,9 +90,6 @@ public class LogoutTest extends AbstractSamlTest {
private ClientRepresentation salesRep; private ClientRepresentation salesRep;
private ClientRepresentation sales2Rep; private ClientRepresentation sales2Rep;
private final AtomicReference<NameIDType> nameIdRef = new AtomicReference<>();
private final AtomicReference<String> sessionIndexRef = new AtomicReference<>();
@Before @Before
public void setup() { public void setup() {
salesRep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST).get(0); salesRep = adminClient.realm(REALM_NAME).clients().findByClientId(SAML_CLIENT_ID_SALES_POST).get(0);
@ -116,22 +113,6 @@ public class LogoutTest extends AbstractSamlTest {
return true; return true;
} }
private SAML2Object extractNameIdAndSessionIndexAndTerminate(SAML2Object so) {
assertThat(so, isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType loginResp1 = (ResponseType) so;
final AssertionType firstAssertion = loginResp1.getAssertions().get(0).getAssertion();
assertThat(firstAssertion, org.hamcrest.Matchers.notNullValue());
assertThat(firstAssertion.getSubject().getSubType().getBaseID(), instanceOf(NameIDType.class));
NameIDType nameId = (NameIDType) firstAssertion.getSubject().getSubType().getBaseID();
AuthnStatementType firstAssertionStatement = (AuthnStatementType) firstAssertion.getStatements().iterator().next();
nameIdRef.set(nameId);
sessionIndexRef.set(firstAssertionStatement.getSessionIndex());
return null;
}
private SamlClientBuilder logIntoUnsignedSalesAppViaIdp() throws IllegalArgumentException, UriBuilderException { private SamlClientBuilder logIntoUnsignedSalesAppViaIdp() throws IllegalArgumentException, UriBuilderException {
return new SamlClientBuilder() return new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, POST).build() .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, POST).build()

View file

@ -0,0 +1,217 @@
/*
* Copyright 2021 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.junit.Test;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.dom.saml.v2.protocol.StatusResponseType;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.util.SamlClientBuilder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
import static org.keycloak.testsuite.util.SamlClient.Binding.POST;
import static org.keycloak.testsuite.util.SamlClient.Binding.SOAP;
public class SOAPBindingTest extends AbstractSamlTest {
@Test
public void soapBindingAuthnWithSignatureTest() {
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, SOAP)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.basicAuthentication(bburkeUser)
.build()
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(ResponseType.class));
ResponseType rt = (ResponseType)response.getSamlObject();
assertThat(rt.getAssertions(), not(empty()));
}
@Test
public void soapBindingAuthnWithSignatureMissingDestinationTest() {
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, SOAP)
.transformObject(authnRequestType -> {
authnRequestType.setDestination(null);
return authnRequestType;
})
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.basicAuthentication(bburkeUser)
.build()
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(ResponseType.class));
ResponseType rt = (ResponseType)response.getSamlObject();
assertThat(rt.getAssertions(), not(empty()));
}
@Test
public void soapBindingAuthnWithoutSignatureTest() {
getCleanup()
.addCleanup(ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_ECP_SP)
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "false")
.setAttribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false")
.update()
);
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, SOAP)
.basicAuthentication(bburkeUser)
.build()
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(ResponseType.class));
ResponseType rt = (ResponseType)response.getSamlObject();
assertThat(rt.getAssertions(), not(empty()));
}
@Test
public void soapBindingAuthnWithoutSignatureMissingDestinationTest() {
getCleanup()
.addCleanup(ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_ECP_SP)
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "false")
.setAttribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false")
.update()
);
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, SOAP)
.transformObject(authnRequestType -> {
authnRequestType.setDestination(null);
return authnRequestType;
})
.basicAuthentication(bburkeUser)
.build()
.executeAndTransform(SOAP::extractResponse);
assertThat(response.getSamlObject(), instanceOf(ResponseType.class));
ResponseType rt = (ResponseType)response.getSamlObject();
assertThat(rt.getAssertions(), not(empty()));
}
@Test
public void soapBindingLogoutWithSignature() {
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, POST)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.build()
.login().user(bburkeUser).build()
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.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);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
}
@Test
public void soapBindingLogoutWithoutSignature() {
getCleanup()
.addCleanup(ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_ECP_SP)
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "false")
.setAttribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false")
.update()
);
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, POST)
.build()
.login().user(bburkeUser).build()
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.build()
.executeAndTransform(POST::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
}
@Test
public void soapBindingLogoutWithSignatureMissingDestinationTest() {
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, POST)
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
.build()
.login().user(bburkeUser).build()
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.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)
.transformObject(logoutRequestType -> {
logoutRequestType.setDestination(null);
return logoutRequestType;
})
.build()
.executeAndTransform(POST::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
}
@Test
public void soapBindingLogoutWithoutSignatureMissingDestinationTest() {
getCleanup()
.addCleanup(ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_ECP_SP)
.setAttribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "false")
.setAttribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false")
.update()
);
SAMLDocumentHolder response = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SAML_ASSERTION_CONSUMER_URL_ECP_SP, POST)
.build()
.login().user(bburkeUser).build()
.processSamlResponse(POST)
.transformObject(this::extractNameIdAndSessionIndexAndTerminate)
.build()
.logoutRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_ECP_SP, SOAP)
.nameId(nameIdRef::get)
.sessionIndex(sessionIndexRef::get)
.transformObject(logoutRequestType -> {
logoutRequestType.setDestination(null);
return logoutRequestType;
})
.build()
.executeAndTransform(POST::extractResponse);
assertThat(response.getSamlObject(), instanceOf(StatusResponseType.class));
}
}

View file

@ -241,12 +241,7 @@ public class DeploymentArchiveProcessorUtils {
public static void modifySAMLAdapterConfig(Archive<?> archive, String adapterConfigPath) { public static void modifySAMLAdapterConfig(Archive<?> archive, String adapterConfigPath) {
Document doc = IOUtil.loadXML(archive.get(adapterConfigPath).getAsset().openStream()); Document doc = IOUtil.loadXML(archive.get(adapterConfigPath).getAsset().openStream());
modifyDocElementAttribute(doc, "SingleSignOnService", "bindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot()); modifySAMLDocument(doc);
modifyDocElementAttribute(doc, "SingleLogoutService", "postBindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot());
modifyDocElementAttribute(doc, "SingleLogoutService", "redirectBindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot());
modifyDocElementAttribute(doc, "SingleSignOnService", "assertionConsumerServiceUrl", AUTH_SERVER_REPLACED_URL, getAppServerContextRoot());
modifyDocElementAttribute(doc, "SP", "logoutPage", AUTH_SERVER_REPLACED_URL, getAppServerContextRoot());
archive.add(new StringAsset(IOUtil.documentToString(doc)), adapterConfigPath); archive.add(new StringAsset(IOUtil.documentToString(doc)), adapterConfigPath);
@ -260,6 +255,15 @@ public class DeploymentArchiveProcessorUtils {
((WebArchive) archive).addAsResource(truststore); ((WebArchive) archive).addAsResource(truststore);
} }
public static void modifySAMLDocument(Document doc) {
modifyDocElementAttribute(doc, "SingleSignOnService", "bindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot());
modifyDocElementAttribute(doc, "SingleLogoutService", "postBindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot());
modifyDocElementAttribute(doc, "SingleLogoutService", "redirectBindingUrl", AUTH_SERVER_REPLACED_URL, getAuthServerContextRoot());
modifyDocElementAttribute(doc, "SingleSignOnService", "assertionConsumerServiceUrl", AUTH_SERVER_REPLACED_URL, getAppServerContextRoot());
modifyDocElementAttribute(doc, "SP", "logoutPage", AUTH_SERVER_REPLACED_URL, getAppServerContextRoot());
}
private static String getAuthServerUrl() { private static String getAuthServerUrl() {
String scheme = AUTH_SERVER_SSL_REQUIRED ? "https" : "http"; String scheme = AUTH_SERVER_SSL_REQUIRED ? "https" : "http";
String host = System.getProperty("auth.server.host", "localhost"); String host = System.getProperty("auth.server.host", "localhost");

View file

@ -28,12 +28,16 @@ import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Result;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer; import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory; import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource; import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamResult;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@ -103,6 +107,19 @@ public class IOUtil {
} }
} }
public static InputStream documentToInputStream(Document doc) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Source xmlSource = new DOMSource(doc);
Result outputTarget = new StreamResult(outputStream);
TransformerFactory.newInstance().newTransformer().transform(xmlSource, outputTarget);
return new ByteArrayInputStream(outputStream.toByteArray());
} catch (TransformerException e) {
log.error("Can't transform document to InputStream");
throw new RuntimeException(e);
}
}
/** /**
* Modifies attribute value according to the given regex (first occurrence) iff * Modifies attribute value according to the given regex (first occurrence) iff
* there are following conditions accomplished: * there are following conditions accomplished: