SoapTest.java

/*
 * Copyright 2016 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.protocol.saml.profile.util;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.xml.soap.SOAPException;
import jakarta.xml.soap.SOAPMessage;
import org.apache.commons.io.IOUtils;
import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.crypto.CryptoProvider;
import org.keycloak.dom.saml.v2.SAML2Object;
import org.keycloak.dom.saml.v2.assertion.NameIDType;
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.saml.SAML2LogoutRequestBuilder;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.common.exceptions.ConfigurationException;
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.services.resteasy.ResteasyKeycloakSession;
import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory;
import org.keycloak.utils.ScopeUtil;
import org.w3c.dom.Document;

/**
 * <p>Test class for Soap utility class.</p>
 *
 * @author rmartinc
 */
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class SoapTest {

    private static HttpServer server;

    private static class MyHandler implements HttpHandler {

        @Override
        public void handle(HttpExchange exchange) throws IOException {
            // just return the same data received, headers inclusive
            if ("POST".equals(exchange.getRequestMethod())) {
                exchange.getResponseHeaders().putAll(exchange.getRequestHeaders());
                try ( InputStream is = exchange.getRequestBody();
                        OutputStream os = exchange.getResponseBody()) {
                    exchange.sendResponseHeaders(200, Long.parseLong(exchange.getRequestHeaders().getFirst(HttpHeaders.CONTENT_LENGTH)));
                    IOUtils.copy(is, os);
                }
            }
            exchange.sendResponseHeaders(400, 0);
        }
    }

    @BeforeClass
    public static void startHttpServer() throws IOException {
        server = HttpServer.create(new InetSocketAddress(8280), 0);
        server.createContext("/", new MyHandler());
        server.setExecutor(null); // creates a default executor
        server.start();
    }

    @AfterClass
    public static void stopHttpServer() {
        server.stop(0);
    }

    private LogoutRequestType createLogoutRequestType() throws ConfigurationException {
        NameIDType nameId = new NameIDType();
        nameId.setFormat(URI.create(JBossSAMLURIConstants.NAMEID_FORMAT_TRANSIENT.get()));
        nameId.setValue("user1");
        return new SAML2LogoutRequestBuilder().assertionExpiration(60).issuer("http://sample.com")
                .nameId(nameId).destination("http://sample.com/logout")
                .sessionIndex("idx")
                .createLogoutRequest();
    }

    @Test
    public void test1ResponseOK() throws Exception {
        LogoutRequestType request = createLogoutRequestType();
        Document doc = SAML2Request.convert(request);
        Profile.defaults();
        CryptoIntegration.init(CryptoProvider.class.getClassLoader());
        ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory();
        sessionFactory.init();
        KeycloakSession session = new ResteasyKeycloakSession(sessionFactory);

        SOAPMessage soapResponse = Soap.createMessage()
                .addMimeHeader("SOAPAction", "http://www.oasis-open.org/committees/security")
                .addMimeHeader("custom-header", "custom-value")
                .addToBody(doc)
                .call("http://localhost:8280", session);
        // check the headers are set back
        Assert.assertArrayEquals(new String[]{"no-cache, no-store"}, soapResponse.getMimeHeaders().getHeader(HttpHeaders.CACHE_CONTROL));
        Assert.assertArrayEquals(new String[]{"http://www.oasis-open.org/committees/security"}, soapResponse.getMimeHeaders().getHeader("SOAPAction"));
        Assert.assertArrayEquals(new String[]{"custom-value"}, soapResponse.getMimeHeaders().getHeader("custom-header"));
        // check response is the LogoutResponseType sent
        Document responseDoc = Soap.extractSoapMessage(soapResponse);
        SAMLDocumentHolder samlDocResponse = SAML2Request.getSAML2ObjectFromDocument(responseDoc);
        SAML2Object samlObject = samlDocResponse.getSamlObject();
        MatcherAssert.assertThat(samlObject, CoreMatchers.instanceOf(LogoutRequestType.class));
        LogoutRequestType response = (LogoutRequestType) samlObject;
        Assert.assertEquals(request.getNameID().getValue(), response.getNameID().getValue());
    }

    @Test
    public void test2ConfigurationUsed() throws Exception {
        LogoutRequestType request = createLogoutRequestType();
        Document doc = SAML2Request.convert(request);
        Profile.defaults();
        CryptoIntegration.init(CryptoProvider.class.getClassLoader());
        Config.init(new Config.ConfigProvider() {
            @Override
            public String getProvider(String spi) {
                return null;
            }

            @Override
            public String getDefaultProvider(String spi) {
                return null;
            }

            @Override
            public Config.Scope scope(String... scope) {
                if (scope.length == 2 && "connectionsHttpClient".equals(scope[0]) && "default".equals(scope[1])) {
                    return ScopeUtil.createScope(Collections.singletonMap("proxy-mappings", "localhost;http://localhost:8281"));
                }
                return ScopeUtil.createScope(new HashMap<>());
            }
        });
        ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory();
        sessionFactory.init();
        KeycloakSession session = new ResteasyKeycloakSession(sessionFactory);

        SOAPException ex = Assert.assertThrows(SOAPException.class, () -> {
            Soap.createMessage()
                .addToBody(doc)
                .call("http://localhost:8280", session);
        });
        MatcherAssert.assertThat(ex.getMessage(), CoreMatchers.containsString("localhost:8281"));
        MatcherAssert.assertThat(ex.getMessage(), CoreMatchers.containsString("Connection refused"));
    }
}