/*
* 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.util;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.LaxRedirectStrategy;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.saml.BaseSAML2BindingBuilder;
import org.keycloak.saml.SAMLRequestParser;
import org.keycloak.saml.SignatureAlgorithm;
import org.keycloak.saml.common.constants.GeneralConstants;
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.processing.api.saml.v2.request.SAML2Request;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.w3c.dom.Document;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.LinkedList;
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.Matchers.statusCodeIsHC;
/**
* @author hmlnarik
*/
public class SamlClient {
/**
* SAML bindings and related HttpClient methods.
*/
public enum Binding {
POST {
@Override
public SAMLDocumentHolder extractResponse(CloseableHttpResponse response) throws IOException {
assertThat(response, statusCodeIsHC(Response.Status.OK));
String responsePage = EntityUtils.toString(response.getEntity(), "UTF-8");
response.close();
return extractSamlResponseFromForm(responsePage);
}
@Override
public HttpPost createSamlUnsignedRequest(URI samlEndpoint, String relayState, Document samlRequest) {
return createSamlPostMessage(samlEndpoint, relayState, samlRequest, GeneralConstants.SAML_REQUEST_KEY, null, null);
}
@Override
public HttpPost createSamlUnsignedResponse(URI samlEndpoint, String relayState, Document samlRequest) {
return createSamlPostMessage(samlEndpoint, relayState, samlRequest, GeneralConstants.SAML_RESPONSE_KEY, null, null);
}
@Override
public HttpPost createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey) {
return createSamlPostMessage(samlEndpoint, relayState, samlRequest, GeneralConstants.SAML_REQUEST_KEY, realmPrivateKey, realmPublicKey);
}
private HttpPost createSamlPostMessage(URI samlEndpoint, String relayState, Document samlRequest, String messageType, String privateKeyStr, String publicKeyStr) {
HttpPost post = new HttpPost(samlEndpoint);
List<NameValuePair> parameters = new LinkedList<>();
try {
BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder();
if (privateKeyStr != null && publicKeyStr != null) {
PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr);
PublicKey publicKey = org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr);
binding
.signatureAlgorithm(SignatureAlgorithm.RSA_SHA256)
.signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey)
.signDocument();
}
parameters.add(
new BasicNameValuePair(messageType,
binding
.postBinding(samlRequest)
.encoded())
);
} catch (IOException | ConfigurationException | ProcessingException ex) {
throw new RuntimeException(ex);
}
if (relayState != null) {
parameters.add(new BasicNameValuePair(GeneralConstants.RELAY_STATE, relayState));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return post;
}
@Override
public URI getBindingUri() {
return URI.create(JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get());
}
},
REDIRECT {
@Override
public SAMLDocumentHolder extractResponse(CloseableHttpResponse response) throws IOException {
assertThat(response, statusCodeIsHC(Response.Status.FOUND));
String location = response.getFirstHeader("Location").getValue();
response.close();
return extractSamlResponseFromRedirect(location);
}
@Override
public HttpGet createSamlUnsignedRequest(URI samlEndpoint, String relayState, Document samlRequest) {
try {
URI requestURI = new BaseSAML2BindingBuilder()
.relayState(relayState)
.redirectBinding(samlRequest)
.requestURI(samlEndpoint.toString());
return new HttpGet(requestURI);
} catch (ProcessingException | ConfigurationException | IOException ex) {
throw new RuntimeException(ex);
}
}
@Override
public URI getBindingUri() {
return URI.create(JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get());
}
@Override
public HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayState, Document samlRequest) {
return null;
}
@Override
public HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey) {
return null;
}
};
public abstract SAMLDocumentHolder extractResponse(CloseableHttpResponse response) throws IOException;
public abstract HttpUriRequest createSamlUnsignedRequest(URI samlEndpoint, String relayState, Document samlRequest);
public abstract HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String realmPrivateKey, String realmPublicKey);
public abstract URI getBindingUri();
public abstract HttpUriRequest createSamlUnsignedResponse(URI samlEndpoint, String relayState, Document samlRequest);
}
public static class RedirectStrategyWithSwitchableFollowRedirect extends LaxRedirectStrategy {
public boolean redirectable = true;
@Override
protected boolean isRedirectable(String method) {
return redirectable && super.isRedirectable(method);
}
public void setRedirectable(boolean redirectable) {
this.redirectable = redirectable;
}
}
/**
* Extracts and parses value of SAMLResponse input field of a form present in the given page.
*
* @param responsePage HTML code of the page
* @return
*/
public static SAMLDocumentHolder extractSamlResponseFromForm(String responsePage) {
org.jsoup.nodes.Document theResponsePage = Jsoup.parse(responsePage);
Elements samlResponses = theResponsePage.select("input[name=SAMLResponse]");
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.isEmpty() ? samlRequests.first() : samlResponses.first();
return SAMLRequestParser.parseResponsePostBinding(respElement.val());
}
/**
* Extracts and parses value of SAMLResponse query parameter from the given URI.
*
* @param responseUri
* @return
*/
public static SAMLDocumentHolder extractSamlResponseFromRedirect(String responseUri) {
List<NameValuePair> params = URLEncodedUtils.parse(URI.create(responseUri), "UTF-8");
String samlDoc = null;
for (NameValuePair param : params) {
if ("SAMLResponse".equals(param.getName()) || "SAMLRequest".equals(param.getName())) {
assertThat("Only one SAMLRequest/SAMLResponse check", samlDoc, nullValue());
samlDoc = param.getValue();
}
}
return SAMLRequestParser.parseResponseRedirectBinding(samlDoc);
}
/**
* Prepares a GET/POST request for logging the given user into the given login page. The login page is expected
* to have at least input fields with id "username" and "password".
*
* @param user
* @param loginPage
* @return
*/
public static HttpUriRequest handleLoginPage(UserRepresentation user, String loginPage) {
String username = user.getUsername();
String password = getPasswordOf(user);
org.jsoup.nodes.Document theLoginPage = Jsoup.parse(loginPage);
List<NameValuePair> parameters = new LinkedList<>();
for (Element form : theLoginPage.getElementsByTag("form")) {
String method = form.attr("method");
String action = form.attr("action");
boolean isPost = method != null && "post".equalsIgnoreCase(method);
for (Element input : form.getElementsByTag("input")) {
if (Objects.equals(input.id(), "username")) {
parameters.add(new BasicNameValuePair(input.attr("name"), username));
} else if (Objects.equals(input.id(), "password")) {
parameters.add(new BasicNameValuePair(input.attr("name"), password));
} else {
parameters.add(new BasicNameValuePair(input.attr("name"), input.val()));
}
}
if (isPost) {
HttpPost res = new HttpPost(action);
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
res.setEntity(formEntity);
return res;
} else {
UriBuilder b = UriBuilder.fromPath(action);
for (NameValuePair parameter : parameters) {
b.queryParam(parameter.getName(), parameter.getValue());
}
return new HttpGet(b.build());
}
}
throw new IllegalArgumentException("Invalid login form: " + loginPage);
}
/**
* Prepares a GET/POST request for consent granting . The consent page is expected
* to have at least input fields with id "kc-login" and "kc-cancel".
*
* @param consentPage
* @param consent
* @return
*/
public static HttpUriRequest handleConsentPage(String consentPage, boolean consent) {
org.jsoup.nodes.Document theLoginPage = Jsoup.parse(consentPage);
List<NameValuePair> parameters = new LinkedList<>();
for (Element form : theLoginPage.getElementsByTag("form")) {
String method = form.attr("method");
String action = form.attr("action");
boolean isPost = method != null && "post".equalsIgnoreCase(method);
for (Element input : form.getElementsByTag("input")) {
if (Objects.equals(input.id(), "kc-login")) {
if (consent)
parameters.add(new BasicNameValuePair(input.attr("name"), input.attr("value")));
} else if (Objects.equals(input.id(), "kc-cancel")) {
if (!consent)
parameters.add(new BasicNameValuePair(input.attr("name"), input.attr("value")));
} else {
parameters.add(new BasicNameValuePair(input.attr("name"), input.val()));
}
}
if (isPost) {
HttpPost res = new HttpPost(getAuthServerContextRoot() + action);
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
res.setEntity(formEntity);
return res;
} else {
UriBuilder b = UriBuilder.fromPath(action);
for (NameValuePair parameter : parameters) {
b.queryParam(parameter.getName(), parameter.getValue());
}
return new HttpGet(b.build());
}
}
throw new IllegalArgumentException("Invalid consent page: " + consentPage);
}
/**
* Creates a SAML login request document with the given parameters. See SAML <AuthnRequest> description for more details.
*
* @param issuer
* @param assertionConsumerURL
* @param destination
* @return
*/
public static AuthnRequestType createLoginRequestDocument(String issuer, String assertionConsumerURL, URI destination) {
try {
SAML2Request samlReq = new SAML2Request();
AuthnRequestType loginReq = samlReq.createAuthnRequestType(UUID.randomUUID().toString(), assertionConsumerURL, destination.toString(), issuer);
return loginReq;
} catch (ConfigurationException ex) {
throw new RuntimeException(ex);
}
}
/**
* Send request for login form and then login using user param. This method is designed for clients without required consent
*
* @param user
* @param samlEndpoint
* @param samlRequest
* @param relayState
* @param requestBinding
* @param expectedResponseBinding
* @return
*/
public static SAMLDocumentHolder login(UserRepresentation user, URI samlEndpoint,
Document samlRequest, String relayState, Binding requestBinding, Binding expectedResponseBinding) {
return new SamlClient(samlEndpoint).login(user, samlRequest, relayState, requestBinding, expectedResponseBinding, false, true);
}
private final HttpClientContext context = HttpClientContext.create();
private final URI samlEndpoint;
public SamlClient(URI samlEndpoint) {
this.samlEndpoint = samlEndpoint;
}
/**
* Send request for login form and then login using user param. Check whether client requires consent and handle consent page.
*
* @param user
* @param samlEndpoint
* @param samlRequest
* @param relayState
* @param requestBinding
* @param expectedResponseBinding
* @param consentRequired
* @param consent
* @return
*/
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);
CloseableHttpResponse response = client.execute(post, context);
assertThat(response, statusCodeIsHC(Response.Status.OK));
String loginPageText = EntityUtils.toString(response.getEntity(), "UTF-8");
response.close();
assertThat(loginPageText, containsString("login"));
HttpUriRequest loginRequest = handleLoginPage(user, loginPageText);
if (consentRequired) {
// Client requires consent
response = client.execute(loginRequest, context);
String consentPageText = EntityUtils.toString(response.getEntity(), "UTF-8");
loginRequest = handleConsentPage(consentPageText, consent);
}
strategy.setRedirectable(false);
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);
} finally {
if (response != null) {
EntityUtils.consumeQuietly(response.getEntity());
try {
response.close();
} catch (IOException ex) {
}
}
}
}
/**
* Send request for login form and then login using user param for clients which doesn't require consent
*
* @param user
* @param idpInitiatedURI
* @param expectedResponseBinding
* @return
*/
public static SAMLDocumentHolder idpInitiatedLogin(UserRepresentation user, URI idpInitiatedURI, Binding expectedResponseBinding) {
return new SamlClient(idpInitiatedURI).idpInitiatedLogin(user, expectedResponseBinding, false, true);
}
/**
* Send request for login form and then login using user param. For clients which requires consent
*
* @param user
* @param idpInitiatedURI
* @param expectedResponseBinding
* @param consent
* @return
*/
public static SAMLDocumentHolder idpInitiatedLoginWithRequiredConsent(UserRepresentation user, URI idpInitiatedURI, Binding expectedResponseBinding, boolean 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 samlEndpoint
* @param expectedResponseBinding
* @param consent
* @return
*/
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));
String loginPageText = EntityUtils.toString(response.getEntity(), "UTF-8");
response.close();
assertThat(loginPageText, containsString("login"));
HttpUriRequest loginRequest = handleLoginPage(user, loginPageText);
if (consentRequired) {
// Client requires consent
response = client.execute(loginRequest, context);
String consentPageText = EntityUtils.toString(response.getEntity(), "UTF-8");
loginRequest = handleConsentPage(consentPageText, consent);
}
strategy.setRedirectable(false);
return client.execute(loginRequest, context);
});
}
}