mirror of https://github.com/jacekkow/keycloak-protocol-cas

Erlend Hamnaberg
2018-11-26 74023ad339616936c5a2415f3b0347858c38df18
Saml 1.1 Validate support

The reasoning for this commit is that this is well supported by CAS,
and it might be reasonable to include this in something that emulates
CAS.

Use Saml lib provided by keycloak
6 files modified
7 files added
1120 ■■■■ changed files
pom.xml 7 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java 1 ●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java 12 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java 175 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java 107 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java 14 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java 150 ●●●●● patch | view | raw | blame | history
src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java 208 ●●●●● patch | view | raw | blame | history
src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java 48 ●●●●● patch | view | raw | blame | history
src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java 30 ●●●● patch | view | raw | blame | history
src/test/java/org/keycloak/protocol/cas/XMLValidator.java 35 ●●●●● patch | view | raw | blame | history
src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd 201 ●●●●● patch | view | raw | blame | history
src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd 132 ●●●●● patch | view | raw | blame | history
pom.xml
@@ -61,6 +61,7 @@
            <groupId>org.jboss.logging</groupId>
            <artifactId>jboss-logging</artifactId>
            <version>${jboss.logging.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.jboss.logging</groupId>
@@ -85,7 +86,7 @@
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-saml-core</artifactId>
            <version>${keycloak.version}</version>
            <scope>test</scope>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
@@ -111,7 +112,7 @@
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <version>3.8.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
@@ -126,7 +127,7 @@
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Dependencies>javax.xml.bind.api,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services</Dependencies>
                            <Dependencies>javax.xml.bind.api,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,org.keycloak.keycloak-saml-core,org.keycloak.keycloak-saml-core-public</Dependencies>
                        </manifestEntries>
                    </archive>
                </configuration>
src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java
@@ -25,6 +25,7 @@
    public static final String LOGIN_PROTOCOL = "cas";
    public static final String SERVICE_PARAM = "service";
    public static final String TARGET_PARAM = "TARGET";
    public static final String RENEW_PARAM = "renew";
    public static final String GATEWAY_PARAM = "gateway";
    public static final String TICKET_PARAM = "ticket";
src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java
@@ -5,10 +5,7 @@
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.cas.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.cas.endpoints.LogoutEndpoint;
import org.keycloak.protocol.cas.endpoints.ServiceValidateEndpoint;
import org.keycloak.protocol.cas.endpoints.ValidateEndpoint;
import org.keycloak.protocol.cas.endpoints.*;
import org.keycloak.services.resources.RealmsResource;
import javax.ws.rs.Path;
@@ -57,6 +54,13 @@
        return endpoint;
    }
    @Path("samlValidate")
    public Object validateSaml11() {
        SamlValidateEndpoint endpoint = new SamlValidateEndpoint(realm, event);
        ResteasyProviderFactory.getInstance().injectProperties(endpoint);
        return endpoint;
    }
    @Path("serviceValidate")
    public Object serviceValidate() {
        ServiceValidateEndpoint endpoint = new ServiceValidateEndpoint(realm, event);
src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java
New file
@@ -0,0 +1,175 @@
package org.keycloak.protocol.cas.endpoints;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.*;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.mappers.CASAttributeMapper;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.util.DefaultClientSessionContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public abstract class AbstractValidateEndpoint {
    protected final Logger logger = Logger.getLogger(getClass());
    @Context
    protected KeycloakSession session;
    @Context
    protected ClientConnection clientConnection;
    @Context
    protected HttpRequest request;
    @Context
    protected HttpHeaders headers;
    protected RealmModel realm;
    protected EventBuilder event;
    protected ClientModel client;
    protected AuthenticatedClientSessionModel clientSession;
    public AbstractValidateEndpoint(RealmModel realm, EventBuilder event) {
        this.realm = realm;
        this.event = event;
    }
    protected void checkSsl() {
        if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN);
        }
    }
    protected void checkRealm() {
        if (!realm.isEnabled()) {
            throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Realm not enabled", Response.Status.FORBIDDEN);
        }
    }
    protected void checkClient(String service) {
        if (service == null) {
            event.error(Errors.INVALID_REQUEST);
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.SERVICE_PARAM, Response.Status.BAD_REQUEST);
        }
        client = realm.getClients().stream()
                .filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol()))
                .filter(c -> RedirectUtils.verifyRedirectUri(session.getContext().getUri(), service, realm, c) != null)
                .findFirst().orElse(null);
        if (client == null) {
            event.error(Errors.CLIENT_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client not found", Response.Status.BAD_REQUEST);
        }
        if (!client.isEnabled()) {
            event.error(Errors.CLIENT_DISABLED);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client disabled", Response.Status.BAD_REQUEST);
        }
        event.client(client.getClientId());
        session.getContext().setClient(client);
    }
    protected void checkTicket(String ticket, boolean requireReauth) {
        if (ticket == null) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TICKET_PARAM, Response.Status.BAD_REQUEST);
        }
        if (!ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST);
        }
        String code = ticket.substring(CASLoginProtocol.SERVICE_TICKET_PREFIX.length());
        String[] parts = code.split("\\.");
        if (parts.length == 4) {
            event.detail(Details.CODE_ID, parts[2]);
        }
        ClientSessionCode.ParseResult<AuthenticatedClientSessionModel> parseResult = ClientSessionCode.parseResult(code, null, session, realm, client, event, AuthenticatedClientSessionModel.class);
        if (parseResult.isAuthSessionNotFound() || parseResult.isIllegalHash()) {
            event.error(Errors.INVALID_CODE);
            // Attempt to use same code twice should invalidate existing clientSession
            AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();
            if (clientSession != null) {
                clientSession.detachFromUserSession();
            }
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
        }
        clientSession = parseResult.getClientSession();
        if (parseResult.isExpiredToken()) {
            event.error(Errors.EXPIRED_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST);
        }
        clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket);
        if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) {
            event.error(Errors.SESSION_EXPIRED);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Interactive authentication was requested but not performed", Response.Status.BAD_REQUEST);
        }
        UserSessionModel userSession = clientSession.getUserSession();
        if (userSession == null) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User session not found", Response.Status.BAD_REQUEST);
        }
        UserModel user = userSession.getUser();
        if (user == null) {
            event.error(Errors.USER_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User not found", Response.Status.BAD_REQUEST);
        }
        if (!user.isEnabled()) {
            event.error(Errors.USER_DISABLED);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User disabled", Response.Status.BAD_REQUEST);
        }
        event.user(userSession.getUser());
        event.session(userSession.getId());
        if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Auth error", Response.Status.BAD_REQUEST);
        }
        if (!AuthenticationManager.isSessionValid(realm, userSession)) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST);
        }
    }
    protected Map<String, Object> getUserAttributes() {
        UserSessionModel userSession = clientSession.getUserSession();
        // CAS protocol does not support scopes, so pass null scopeParam
        ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, null);
        Set<ProtocolMapperModel> mappings = clientSessionCtx.getProtocolMappers();
        KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
        Map<String, Object> attributes = new HashMap<>();
        for (ProtocolMapperModel mapping : mappings) {
            ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapping.getProtocolMapper());
            if (mapper instanceof CASAttributeMapper) {
                ((CASAttributeMapper) mapper).setAttribute(attributes, mapping, userSession, session, clientSessionCtx);
            }
        }
        return attributes;
    }
}
src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java
New file
@@ -0,0 +1,107 @@
package org.keycloak.protocol.cas.endpoints;
import org.keycloak.dom.saml.v1.protocol.SAML11ResponseType;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.representations.SamlResponseHelper;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.services.Urls;
import org.xml.sax.InputSource;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.xml.namespace.NamespaceContext;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import java.io.StringReader;
import java.util.*;
import static org.keycloak.protocol.cas.CASLoginProtocol.TARGET_PARAM;
public class SamlValidateEndpoint extends AbstractValidateEndpoint {
    public SamlValidateEndpoint(RealmModel realm, EventBuilder event) {
        super(realm, event.event(EventType.CODE_TO_TOKEN));
    }
    @POST
    @Consumes("text/xml;charset=utf-8")
    @Produces("text/xml;charset=utf-8")
    public Response validate(String input) {
        MultivaluedMap<String, String> queryParams = request.getUri().getQueryParameters();
        try {
            String soapAction = Optional.ofNullable(request.getHttpHeaders().getHeaderString("SOAPAction")).map(s -> s.trim().replace("\"", "")).orElse("");
            if (!soapAction.equals("http://www.oasis-open.org/committees/security")) {
                throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Not a validation request", Response.Status.BAD_REQUEST);
            }
            String service = queryParams.getFirst(TARGET_PARAM);
            boolean renew = queryParams.containsKey(CASLoginProtocol.RENEW_PARAM);
            checkRealm();
            checkSsl();
            checkClient(service);
            String issuer = Urls.realmIssuer(request.getUri().getBaseUri(), realm.getName());
            String ticket = getTicket(input);
            checkTicket(ticket, renew);
            UserModel user = clientSession.getUserSession().getUser();
            Map<String, Object> attributes = getUserAttributes();
            SAML11ResponseType response = SamlResponseHelper.successResponse(issuer, user.getUsername(), attributes);
            return Response.ok(SamlResponseHelper.soap(response)).build();
        } catch (CASValidationException ex) {
            logger.warnf("Invalid SAML1.1 token %s", ex.getErrorDescription());
            SAML11ResponseType response = SamlResponseHelper.errorResponse(ex);
            return Response.ok().entity(SamlResponseHelper.soap(response)).build();
        }
    }
    private String getTicket(String input) {
        try {
            XPath xPath = XPathFactory.newInstance().newXPath();
            xPath.setNamespaceContext(new MapNamespaceContext(Collections.singletonMap("samlp", "urn:oasis:names:tc:SAML:1.0:protocol")));
            XPathExpression expression = xPath.compile("//samlp:AssertionArtifact/text()");
            return expression.evaluate(new InputSource(new StringReader(input)));
        } catch (XPathExpressionException ex) {
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, ex.getMessage(), Response.Status.BAD_REQUEST);
        }
    }
    private static class MapNamespaceContext implements NamespaceContext {
        Map<String, String> map;
        private MapNamespaceContext(Map<String, String> map) {
            this.map = map;
        }
        @Override
        public String getNamespaceURI(String s) {
            return map.get(s);
        }
        @Override
        public String getPrefix(String s) {
            return map.entrySet().stream().filter(e -> e.getValue().equals(s)).findFirst().map(Map.Entry::getKey).orElse(null);
        }
        @Override
        public Iterator<String> getPrefixes(String s) {
            return map.keySet().iterator();
        }
    }
}
src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java
@@ -27,19 +27,7 @@
    @Override
    protected Response successResponse() {
        UserSessionModel userSession = clientSession.getUserSession();
        // CAS protocol does not support scopes, so pass null scopeParam
        ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, null);
        Set<ProtocolMapperModel> mappings = clientSessionCtx.getProtocolMappers();
        KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
        Map<String, Object> attributes = new HashMap<>();
        for (ProtocolMapperModel mapping : mappings) {
            ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapping.getProtocolMapper());
            if (mapper instanceof CASAttributeMapper) {
                ((CASAttributeMapper) mapper).setAttribute(attributes, mapping, userSession, session, clientSessionCtx);
            }
        }
        Map<String, Object> attributes = getUserAttributes();
        CASServiceResponse serviceResponse = ServiceResponseHelper.createSuccess(userSession.getUser().getUsername(), attributes);
        return prepare(Response.Status.OK, serviceResponse);
    }
src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java
@@ -1,50 +1,24 @@
package org.keycloak.protocol.cas.endpoints;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.*;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import javax.ws.rs.GET;
import javax.ws.rs.core.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
public class ValidateEndpoint {
    protected static final Logger logger = Logger.getLogger(ValidateEndpoint.class);
public class ValidateEndpoint extends AbstractValidateEndpoint {
    private static final String RESPONSE_OK = "yes\n";
    private static final String RESPONSE_FAILED = "no\n";
    @Context
    protected KeycloakSession session;
    @Context
    protected ClientConnection clientConnection;
    @Context
    protected HttpRequest request;
    @Context
    protected HttpHeaders headers;
    protected RealmModel realm;
    protected EventBuilder event;
    protected ClientModel client;
    protected AuthenticatedClientSessionModel clientSession;
    public ValidateEndpoint(RealmModel realm, EventBuilder event) {
        this.realm = realm;
        this.event = event;
        super(realm, event);
    }
    @GET
@@ -79,116 +53,4 @@
        return Response.status(e.getStatus()).entity(RESPONSE_FAILED).type(MediaType.TEXT_PLAIN).build();
    }
    private void checkSsl() {
        if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN);
        }
    }
    private void checkRealm() {
        if (!realm.isEnabled()) {
            throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Realm not enabled", Response.Status.FORBIDDEN);
        }
    }
    private void checkClient(String service) {
        if (service == null) {
            event.error(Errors.INVALID_REQUEST);
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.SERVICE_PARAM, Response.Status.BAD_REQUEST);
        }
        client = realm.getClients().stream()
                .filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol()))
                .filter(c -> RedirectUtils.verifyRedirectUri(session.getContext().getUri(), service, realm, c) != null)
                .findFirst().orElse(null);
        if (client == null) {
            event.error(Errors.CLIENT_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client not found", Response.Status.BAD_REQUEST);
        }
        if (!client.isEnabled()) {
            event.error(Errors.CLIENT_DISABLED);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client disabled", Response.Status.BAD_REQUEST);
        }
        event.client(client.getClientId());
        session.getContext().setClient(client);
    }
    private void checkTicket(String ticket, boolean requireReauth) {
        if (ticket == null) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TICKET_PARAM, Response.Status.BAD_REQUEST);
        }
        if (!ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST);
        }
        String code = ticket.substring(CASLoginProtocol.SERVICE_TICKET_PREFIX.length());
        String[] parts = code.split("\\.");
        if (parts.length == 4) {
            event.detail(Details.CODE_ID, parts[2]);
        }
        ClientSessionCode.ParseResult<AuthenticatedClientSessionModel> parseResult = ClientSessionCode.parseResult(code, null, session, realm, client, event, AuthenticatedClientSessionModel.class);
        if (parseResult.isAuthSessionNotFound() || parseResult.isIllegalHash()) {
            event.error(Errors.INVALID_CODE);
            // Attempt to use same code twice should invalidate existing clientSession
            AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();
            if (clientSession != null) {
                clientSession.detachFromUserSession();
            }
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
        }
        clientSession = parseResult.getClientSession();
        if (parseResult.isExpiredToken()) {
            event.error(Errors.EXPIRED_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST);
        }
        clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket);
        if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) {
            event.error(Errors.SESSION_EXPIRED);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Interactive authentication was requested but not performed", Response.Status.BAD_REQUEST);
        }
        UserSessionModel userSession = clientSession.getUserSession();
        if (userSession == null) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User session not found", Response.Status.BAD_REQUEST);
        }
        UserModel user = userSession.getUser();
        if (user == null) {
            event.error(Errors.USER_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User not found", Response.Status.BAD_REQUEST);
        }
        if (!user.isEnabled()) {
            event.error(Errors.USER_DISABLED);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User disabled", Response.Status.BAD_REQUEST);
        }
        event.user(userSession.getUser());
        event.session(userSession.getId());
        if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
            event.error(Errors.INVALID_CODE);
            throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Auth error", Response.Status.BAD_REQUEST);
        }
        if (!AuthenticationManager.isSessionValid(realm, userSession)) {
            event.error(Errors.USER_SESSION_NOT_FOUND);
            throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST);
        }
    }
}
src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java
New file
@@ -0,0 +1,208 @@
package org.keycloak.protocol.cas.representations;
import org.keycloak.dom.saml.v1.assertion.*;
import org.keycloak.dom.saml.v1.protocol.SAML11ResponseType;
import org.keycloak.dom.saml.v1.protocol.SAML11StatusCodeType;
import org.keycloak.dom.saml.v1.protocol.SAML11StatusType;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.processing.core.saml.v1.SAML11Constants;
import org.keycloak.saml.processing.core.saml.v1.writers.SAML11ResponseWriter;
import org.keycloak.services.validation.Validation;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import javax.xml.datatype.DatatypeConfigurationException;
import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMResult;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.net.URI;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class SamlResponseHelper {
    private final static DatatypeFactory factory;
    static {
        try {
            factory = DatatypeFactory.newInstance();
        } catch (DatatypeConfigurationException e) {
            throw new RuntimeException(e);
        }
    }
    public static SAML11ResponseType errorResponse(CASValidationException ex) {
        ZonedDateTime nowZoned = ZonedDateTime.now(ZoneOffset.UTC);
        XMLGregorianCalendar now = factory.newXMLGregorianCalendar(GregorianCalendar.from(nowZoned));
        return applyTo(new SAML11ResponseType("_" + UUID.randomUUID().toString(), now), obj -> {
            obj.setStatus(applyTo(new SAML11StatusType(), status -> {
                status.setStatusCode(new SAML11StatusCodeType(QName.valueOf("samlp:RequestDenied")));
                status.setStatusMessage(ex.getErrorDescription());
            }));
        });
    }
    public static SAML11ResponseType successResponse(String issuer, String username, Map<String, Object> attributes) {
        ZonedDateTime nowZoned = ZonedDateTime.now(ZoneOffset.UTC);
        XMLGregorianCalendar now = factory.newXMLGregorianCalendar(GregorianCalendar.from(nowZoned));
        return applyTo(new SAML11ResponseType("_" + UUID.randomUUID().toString(), now),
                obj -> {
                    obj.setStatus(applyTo(new SAML11StatusType(), status -> status.setStatusCode(SAML11StatusCodeType.SUCCESS)));
                    obj.add(applyTo(new SAML11AssertionType("_" + UUID.randomUUID().toString(), now), assertion -> {
                        assertion.setIssuer(issuer);
                        assertion.setConditions(applyTo(new SAML11ConditionsType(), conditions -> {
                            conditions.setNotBefore(now);
                            conditions.setNotOnOrAfter(factory.newXMLGregorianCalendar(GregorianCalendar.from(nowZoned.plusMinutes(5))));
                        }));
                        assertion.add(applyTo(new SAML11AuthenticationStatementType(
                                URI.create(SAML11Constants.AUTH_METHOD_PASSWORD),
                                now
                        ), stmt -> stmt.setSubject(toSubject(username))));
                        assertion.addAllStatements(toAttributes(username, attributes));
                    }));
                }
        );
    }
    private static List<SAML11StatementAbstractType> toAttributes(String username, Map<String, Object> attributes) {
        List<SAML11AttributeType> converted = attributeElements(attributes);
        if (converted.isEmpty()) {
            return Collections.emptyList();
        }
        return Collections.singletonList(applyTo(
                new SAML11AttributeStatementType(),
                attrs -> {
                    attrs.setSubject(toSubject(username));
                    attrs.addAllAttributes(converted);
                })
        );
    }
    private static List<SAML11AttributeType> attributeElements(Map<String, Object> attributes) {
        return attributes.entrySet().stream().flatMap(e ->
                toAttribute(e.getKey(), e.getValue())
        ).filter(a -> !a.get().isEmpty()).collect(Collectors.toList());
    }
    private static Stream<SAML11AttributeType> toAttribute(String name, Object value) {
        if (name == null || value == null) {
            return Stream.empty();
        }
        if (value instanceof Collection) {
            return Stream.of(samlAttribute(name, listString((Collection<?>) value)));
        }
        return Stream.of(samlAttribute(name, Collections.singletonList(value.toString())));
    }
    private static SAML11AttributeType samlAttribute(String name, List<Object> listString) {
        return applyTo(
                new SAML11AttributeType(name, URI.create("http://www.ja-sig.org/products/cas/")),
                attr -> attr.addAll(listString)
        );
    }
    private static List<Object> listString(Collection<?> value) {
        return value.stream().map(Object::toString).collect(Collectors.toList());
    }
    private static SAML11SubjectType toSubject(String username) {
        return applyTo(
                new SAML11SubjectType(),
                subject -> subject.setChoice(
                        new SAML11SubjectType.SAML11SubjectTypeChoice(
                                applyTo(
                                        new SAML11NameIdentifierType(username),
                                        ctype -> ctype.setFormat(nameIdFormat(username))
                                )
                        )
                )
        );
    }
    private static URI nameIdFormat(String username) {
        return URI.create(Validation.isEmailValid(username) ?
                SAML11Constants.FORMAT_EMAIL_ADDRESS :
                SAML11Constants.FORMAT_UNSPECIFIED
        );
    }
    private static <A> A applyTo(A input, Consumer<A> setter) {
        setter.accept(input);
        return input;
    }
    public static String soap(SAML11ResponseType response) {
        try {
            Document result = toDOM(response);
            Document doc = wrapSoap(result.getDocumentElement());
            return toString(doc);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    public static Document toDOM(SAML11ResponseType response) throws ParserConfigurationException, XMLStreamException, ProcessingException {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        XMLOutputFactory factory = XMLOutputFactory.newFactory();
        Document doc = dbf.newDocumentBuilder().newDocument();
        DOMResult result = new DOMResult(doc);
        XMLStreamWriter xmlWriter = factory.createXMLStreamWriter(result);
        SAML11ResponseWriter writer = new SAML11ResponseWriter(xmlWriter);
        writer.write(response);
        return doc;
    }
    private static Document wrapSoap(Node node) throws ParserConfigurationException {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        Document doc = dbf.newDocumentBuilder().newDocument();
        Element envelope = doc.createElementNS("http://schemas.xmlsoap.org/soap/envelope/", "soap:Envelope");
        envelope.appendChild(doc.createElementNS("http://schemas.xmlsoap.org/soap/envelope/", "soap:Header"));
        Element body = doc.createElementNS("http://schemas.xmlsoap.org/soap/envelope/", "soap:Body");
        Node imported = doc.importNode(node, true);
        body.appendChild(imported);
        doc.appendChild(body);
        envelope.appendChild(body);
        doc.appendChild(envelope);
        return doc;
    }
    public static String toString(Document document) throws TransformerException {
        // Output the Document
        TransformerFactory tf = TransformerFactory.newInstance();
        Transformer t = tf.newTransformer();
        DOMSource source = new DOMSource(document);
        StringWriter writer = new StringWriter();
        StreamResult result = new StreamResult(writer);
        t.transform(source, result);
        return writer.toString();
    }
}
src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java
New file
@@ -0,0 +1,48 @@
package org.keycloak.protocol.cas;
import org.junit.Test;
import org.keycloak.dom.saml.v1.protocol.SAML11ResponseType;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.representations.SamlResponseHelper;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.w3c.dom.Document;
import javax.ws.rs.core.Response;
import java.util.Collections;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class SamlResponseTest {
    @Test
    public void successResponseIsWrappedInSOAP() {
        SAML11ResponseType response = SamlResponseHelper.successResponse("keycloak", "test@example.com", Collections.emptyMap());
        String soapResult = SamlResponseHelper.soap(response);
        assertTrue(soapResult.contains("samlp:Success"));
        assertTrue(soapResult.contains("test@example.com"));
        assertTrue(soapResult.contains("keycloak"));
    }
    @Test
    public void failureResponseIsWrappedInSOAP() {
        SAML11ResponseType response = SamlResponseHelper.errorResponse(new CASValidationException(CASErrorCode.INVALID_TICKET, "Nope", Response.Status.BAD_REQUEST));
        String nope = SamlResponseHelper.soap(response);
        assertTrue(nope.contains("Nope"));
    }
    @Test
    public void validateSchemaResponseFailure() throws Exception {
        SAML11ResponseType response = SamlResponseHelper.errorResponse(new CASValidationException(CASErrorCode.INVALID_TICKET, "Nope", Response.Status.BAD_REQUEST));
        String output = SamlResponseHelper.toString(SamlResponseHelper.toDOM(response));
        Document doc = XMLValidator.parseAndValidate(output, XMLValidator.schemaFromClassPath("oasis-sstc-saml-schema-protocol-1.1.xsd"));
        assertNotNull(doc);
    }
    @Test
    public void validateSchemaResponseSuccess() throws Exception {
        SAML11ResponseType response = SamlResponseHelper.successResponse("keycloak", "test@example.com", Collections.emptyMap());
        String output = SamlResponseHelper.toString(SamlResponseHelper.toDOM(response));
        Document doc = XMLValidator.parseAndValidate(output, XMLValidator.schemaFromClassPath("oasis-sstc-saml-schema-protocol-1.1.xsd"));
        assertNotNull(doc);
    }
}
src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java
@@ -1,7 +1,6 @@
package org.keycloak.protocol.cas;
import com.jayway.jsonpath.JsonPath;
import com.sun.xml.bind.v2.util.FatalAdapter;
import org.junit.Test;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.representations.CASServiceResponse;
@@ -9,20 +8,14 @@
import org.keycloak.protocol.cas.utils.ServiceResponseMarshaller;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.xml.sax.helpers.DefaultHandler;
import org.xmlunit.xpath.JAXPXPathEngine;
import org.xmlunit.xpath.XPathEngine;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static org.junit.Assert.assertEquals;
import static org.keycloak.protocol.cas.XMLValidator.parseAndValidate;
import static org.keycloak.protocol.cas.XMLValidator.schemaFromClassPath;
public class ServiceResponseTest {
    private final XPathEngine xpath = new JAXPXPathEngine();
@@ -55,7 +48,7 @@
        // Build and validate XML response
        String xml = ServiceResponseMarshaller.marshalXml(response);
        Document doc = parseAndValidate(xml);
        Document doc = parseAndValidate(xml, schemaFromClassPath("cas-response-schema.xsd"));
        assertEquals("username", xpath.evaluate("/cas:serviceResponse/cas:authenticationSuccess/cas:user", doc));
        int idx = 0;
        for (Node node : xpath.selectNodes("/cas:serviceResponse/cas:authenticationSuccess/cas:attributes/cas:list", doc)) {
@@ -88,23 +81,8 @@
        // Build and validate XML response
        String xml = ServiceResponseMarshaller.marshalXml(response);
        Document doc = parseAndValidate(xml);
        Document doc = parseAndValidate(xml, schemaFromClassPath("cas-response-schema.xsd"));
        assertEquals(CASErrorCode.INVALID_REQUEST.name(), xpath.evaluate("/cas:serviceResponse/cas:authenticationFailure/@code", doc));
        assertEquals("Error description", xpath.evaluate("/cas:serviceResponse/cas:authenticationFailure", doc));
    }
    /**
     * Parse XML document and validate against CAS schema
     */
    private Document parseAndValidate(String xml) throws Exception {
        Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
                .newSchema(getClass().getResource("cas-response-schema.xsd"));
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setSchema(schema);
        factory.setNamespaceAware(true);
        DocumentBuilder builder = factory.newDocumentBuilder();
        builder.setErrorHandler(new FatalAdapter(new DefaultHandler()));
        return builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
    }
}
src/test/java/org/keycloak/protocol/cas/XMLValidator.java
New file
@@ -0,0 +1,35 @@
package org.keycloak.protocol.cas;
import com.sun.xml.bind.v2.util.FatalAdapter;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import java.io.StringReader;
public abstract class XMLValidator {
    private XMLValidator(){}
    public static Schema schemaFromClassPath(String path) throws SAXException {
        return SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
                .newSchema(XMLValidator.class.getResource(path));
    }
    /**
     * Parse XML document and validate against CAS schema
     */
    public static Document parseAndValidate(String xml, Schema schema) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setSchema(schema);
        factory.setNamespaceAware(true);
        DocumentBuilder builder = factory.newDocumentBuilder();
        builder.setErrorHandler(new FatalAdapter(new DefaultHandler()));
        return builder.parse(new InputSource(new StringReader(xml)));
    }
}
src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd
New file
@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="UTF-8"?>
<schema targetNamespace="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns="http://www.w3.org/2001/XMLSchema" elementFormDefault="unqualified" attributeFormDefault="unqualified" version="1.1">
    <import namespace="http://www.w3.org/2000/09/xmldsig#" schemaLocation="http://www.w3.org/TR/xmldsig-core/xmldsig-core-schema.xsd"/>
    <annotation>
        <documentation>
                Document identifier: oasis-sstc-saml-schema-assertion-1.1
                Location: http://www.oasis-open.org/committees/documents.php?wg_abbrev=security
                Revision history:
                V1.0 (November, 2002):
                  Initial standard schema.
                V1.1 (September, 2003):
                  * Note that V1.1 of this schema has the same XML namespace as V1.0.
                  Rebased ID content directly on XML Schema types
                  Added DoNotCacheCondition element and DoNotCacheConditionType
        </documentation>
    </annotation>
    <simpleType name="DecisionType">
        <restriction base="string">
            <enumeration value="Permit"/>
            <enumeration value="Deny"/>
            <enumeration value="Indeterminate"/>
        </restriction>
    </simpleType>
    <element name="AssertionIDReference" type="NCName"/>
    <element name="Assertion" type="saml:AssertionType"/>
    <complexType name="AssertionType">
        <sequence>
            <element ref="saml:Conditions" minOccurs="0"/>
            <element ref="saml:Advice" minOccurs="0"/>
            <choice maxOccurs="unbounded">
                <element ref="saml:Statement"/>
                <element ref="saml:SubjectStatement"/>
                <element ref="saml:AuthenticationStatement"/>
                <element ref="saml:AuthorizationDecisionStatement"/>
                <element ref="saml:AttributeStatement"/>
            </choice>
            <element ref="ds:Signature" minOccurs="0"/>
        </sequence>
        <attribute name="MajorVersion" type="integer" use="required"/>
        <attribute name="MinorVersion" type="integer" use="required"/>
        <attribute name="AssertionID" type="ID" use="required"/>
        <attribute name="Issuer" type="string" use="required"/>
        <attribute name="IssueInstant" type="dateTime" use="required"/>
    </complexType>
    <element name="Conditions" type="saml:ConditionsType"/>
    <complexType name="ConditionsType">
        <choice minOccurs="0" maxOccurs="unbounded">
            <element ref="saml:AudienceRestrictionCondition"/>
            <element ref="saml:DoNotCacheCondition"/>
            <element ref="saml:Condition"/>
        </choice>
        <attribute name="NotBefore" type="dateTime" use="optional"/>
        <attribute name="NotOnOrAfter" type="dateTime" use="optional"/>
    </complexType>
    <element name="Condition" type="saml:ConditionAbstractType"/>
    <complexType name="ConditionAbstractType" abstract="true"/>
    <element name="AudienceRestrictionCondition" type="saml:AudienceRestrictionConditionType"/>
    <complexType name="AudienceRestrictionConditionType">
        <complexContent>
            <extension base="saml:ConditionAbstractType">
                <sequence>
                    <element ref="saml:Audience" maxOccurs="unbounded"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="Audience" type="anyURI"/>
    <element name="DoNotCacheCondition" type="saml:DoNotCacheConditionType"/>
    <complexType name="DoNotCacheConditionType">
        <complexContent>
            <extension base="saml:ConditionAbstractType"/>
        </complexContent>
    </complexType>
    <element name="Advice" type="saml:AdviceType"/>
    <complexType name="AdviceType">
        <choice minOccurs="0" maxOccurs="unbounded">
            <element ref="saml:AssertionIDReference"/>
            <element ref="saml:Assertion"/>
            <any namespace="##other" processContents="lax"/>
        </choice>
    </complexType>
    <element name="Statement" type="saml:StatementAbstractType"/>
    <complexType name="StatementAbstractType" abstract="true"/>
    <element name="SubjectStatement" type="saml:SubjectStatementAbstractType"/>
    <complexType name="SubjectStatementAbstractType" abstract="true">
        <complexContent>
            <extension base="saml:StatementAbstractType">
                <sequence>
                    <element ref="saml:Subject"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="Subject" type="saml:SubjectType"/>
    <complexType name="SubjectType">
        <choice>
            <sequence>
                <element ref="saml:NameIdentifier"/>
                <element ref="saml:SubjectConfirmation" minOccurs="0"/>
            </sequence>
            <element ref="saml:SubjectConfirmation"/>
        </choice>
    </complexType>
    <element name="NameIdentifier" type="saml:NameIdentifierType"/>
    <complexType name="NameIdentifierType">
        <simpleContent>
            <extension base="string">
                <attribute name="NameQualifier" type="string" use="optional"/>
                <attribute name="Format" type="anyURI" use="optional"/>
            </extension>
        </simpleContent>
    </complexType>
    <element name="SubjectConfirmation" type="saml:SubjectConfirmationType"/>
    <complexType name="SubjectConfirmationType">
        <sequence>
            <element ref="saml:ConfirmationMethod" maxOccurs="unbounded"/>
            <element ref="saml:SubjectConfirmationData" minOccurs="0"/>
            <element ref="ds:KeyInfo" minOccurs="0"/>
        </sequence>
    </complexType>
    <element name="SubjectConfirmationData" type="anyType"/>
    <element name="ConfirmationMethod" type="anyURI"/>
    <element name="AuthenticationStatement" type="saml:AuthenticationStatementType"/>
    <complexType name="AuthenticationStatementType">
        <complexContent>
            <extension base="saml:SubjectStatementAbstractType">
                <sequence>
                    <element ref="saml:SubjectLocality" minOccurs="0"/>
                    <element ref="saml:AuthorityBinding" minOccurs="0" maxOccurs="unbounded"/>
                </sequence>
                <attribute name="AuthenticationMethod" type="anyURI" use="required"/>
                <attribute name="AuthenticationInstant" type="dateTime" use="required"/>
            </extension>
        </complexContent>
    </complexType>
    <element name="SubjectLocality" type="saml:SubjectLocalityType"/>
    <complexType name="SubjectLocalityType">
        <attribute name="IPAddress" type="string" use="optional"/>
        <attribute name="DNSAddress" type="string" use="optional"/>
    </complexType>
    <element name="AuthorityBinding" type="saml:AuthorityBindingType"/>
    <complexType name="AuthorityBindingType">
        <attribute name="AuthorityKind" type="QName" use="required"/>
        <attribute name="Location" type="anyURI" use="required"/>
        <attribute name="Binding" type="anyURI" use="required"/>
    </complexType>
    <element name="AuthorizationDecisionStatement" type="saml:AuthorizationDecisionStatementType"/>
    <complexType name="AuthorizationDecisionStatementType">
        <complexContent>
            <extension base="saml:SubjectStatementAbstractType">
                <sequence>
                    <element ref="saml:Action" maxOccurs="unbounded"/>
                    <element ref="saml:Evidence" minOccurs="0"/>
                </sequence>
                <attribute name="Resource" type="anyURI" use="required"/>
                <attribute name="Decision" type="saml:DecisionType" use="required"/>
            </extension>
        </complexContent>
    </complexType>
    <element name="Action" type="saml:ActionType"/>
    <complexType name="ActionType">
        <simpleContent>
            <extension base="string">
                <attribute name="Namespace" type="anyURI"/>
            </extension>
        </simpleContent>
    </complexType>
    <element name="Evidence" type="saml:EvidenceType"/>
    <complexType name="EvidenceType">
        <choice maxOccurs="unbounded">
            <element ref="saml:AssertionIDReference"/>
            <element ref="saml:Assertion"/>
        </choice>
    </complexType>
    <element name="AttributeStatement" type="saml:AttributeStatementType"/>
    <complexType name="AttributeStatementType">
        <complexContent>
            <extension base="saml:SubjectStatementAbstractType">
                <sequence>
                    <element ref="saml:Attribute" maxOccurs="unbounded"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="AttributeDesignator" type="saml:AttributeDesignatorType"/>
    <complexType name="AttributeDesignatorType">
        <attribute name="AttributeName" type="string" use="required"/>
        <attribute name="AttributeNamespace" type="anyURI" use="required"/>
    </complexType>
    <element name="Attribute" type="saml:AttributeType"/>
    <complexType name="AttributeType">
        <complexContent>
            <extension base="saml:AttributeDesignatorType">
                <sequence>
                    <element ref="saml:AttributeValue" maxOccurs="unbounded"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="AttributeValue" type="anyType"/>
</schema>
src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd
New file
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<schema targetNamespace="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol" xmlns="http://www.w3.org/2001/XMLSchema" elementFormDefault="unqualified" attributeFormDefault="unqualified" version="1.1">
    <import namespace="urn:oasis:names:tc:SAML:1.0:assertion" schemaLocation="oasis-sstc-saml-schema-assertion-1.1.xsd"/>
    <import namespace="http://www.w3.org/2000/09/xmldsig#" schemaLocation="http://www.w3.org/TR/xmldsig-core/xmldsig-core-schema.xsd"/>
    <annotation>
        <documentation>
                Document identifier: oasis-sstc-saml-schema-protocol-1.1
                Location: http://www.oasis-open.org/committees/documents.php?wg_abbrev=security
                Revision history:
                V1.0 (November, 2002):
                  Initial standard schema.
                V1.1 (September, 2003):
                  * Note that V1.1 of this schema has the same XML namespace as V1.0.
                  Rebased ID content directly on XML Schema types
        </documentation>
    </annotation>
    <complexType name="RequestAbstractType" abstract="true">
        <sequence>
            <element ref="samlp:RespondWith" minOccurs="0" maxOccurs="unbounded"/>
            <element ref="ds:Signature" minOccurs="0"/>
        </sequence>
        <attribute name="RequestID" type="ID" use="required"/>
        <attribute name="MajorVersion" type="integer" use="required"/>
        <attribute name="MinorVersion" type="integer" use="required"/>
        <attribute name="IssueInstant" type="dateTime" use="required"/>
    </complexType>
    <element name="RespondWith" type="QName"/>
    <element name="Request" type="samlp:RequestType"/>
    <complexType name="RequestType">
        <complexContent>
            <extension base="samlp:RequestAbstractType">
                <choice>
                    <element ref="samlp:Query"/>
                    <element ref="samlp:SubjectQuery"/>
                    <element ref="samlp:AuthenticationQuery"/>
                    <element ref="samlp:AttributeQuery"/>
                    <element ref="samlp:AuthorizationDecisionQuery"/>
                    <element ref="saml:AssertionIDReference" maxOccurs="unbounded"/>
                    <element ref="samlp:AssertionArtifact" maxOccurs="unbounded"/>
                </choice>
            </extension>
        </complexContent>
    </complexType>
    <element name="AssertionArtifact" type="string"/>
    <element name="Query" type="samlp:QueryAbstractType"/>
    <complexType name="QueryAbstractType" abstract="true"/>
    <element name="SubjectQuery" type="samlp:SubjectQueryAbstractType"/>
    <complexType name="SubjectQueryAbstractType" abstract="true">
        <complexContent>
            <extension base="samlp:QueryAbstractType">
                <sequence>
                    <element ref="saml:Subject"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="AuthenticationQuery" type="samlp:AuthenticationQueryType"/>
    <complexType name="AuthenticationQueryType">
        <complexContent>
            <extension base="samlp:SubjectQueryAbstractType">
                <attribute name="AuthenticationMethod" type="anyURI"/>
            </extension>
        </complexContent>
    </complexType>
    <element name="AttributeQuery" type="samlp:AttributeQueryType"/>
    <complexType name="AttributeQueryType">
        <complexContent>
            <extension base="samlp:SubjectQueryAbstractType">
                <sequence>
                    <element ref="saml:AttributeDesignator" minOccurs="0" maxOccurs="unbounded"/>
                </sequence>
                <attribute name="Resource" type="anyURI" use="optional"/>
            </extension>
        </complexContent>
    </complexType>
    <element name="AuthorizationDecisionQuery" type="samlp:AuthorizationDecisionQueryType"/>
    <complexType name="AuthorizationDecisionQueryType">
        <complexContent>
            <extension base="samlp:SubjectQueryAbstractType">
                <sequence>
                    <element ref="saml:Action" maxOccurs="unbounded"/>
                    <element ref="saml:Evidence" minOccurs="0"/>
                </sequence>
                <attribute name="Resource" type="anyURI" use="required"/>
            </extension>
        </complexContent>
    </complexType>
    <complexType name="ResponseAbstractType" abstract="true">
        <sequence>
            <element ref="ds:Signature" minOccurs="0"/>
        </sequence>
        <attribute name="ResponseID" type="ID" use="required"/>
        <attribute name="InResponseTo" type="NCName" use="optional"/>
        <attribute name="MajorVersion" type="integer" use="required"/>
        <attribute name="MinorVersion" type="integer" use="required"/>
        <attribute name="IssueInstant" type="dateTime" use="required"/>
        <attribute name="Recipient" type="anyURI" use="optional"/>
    </complexType>
    <element name="Response" type="samlp:ResponseType"/>
    <complexType name="ResponseType">
        <complexContent>
            <extension base="samlp:ResponseAbstractType">
                <sequence>
                    <element ref="samlp:Status"/>
                    <element ref="saml:Assertion" minOccurs="0" maxOccurs="unbounded"/>
                </sequence>
            </extension>
        </complexContent>
    </complexType>
    <element name="Status" type="samlp:StatusType"/>
    <complexType name="StatusType">
        <sequence>
            <element ref="samlp:StatusCode"/>
            <element ref="samlp:StatusMessage" minOccurs="0"/>
            <element ref="samlp:StatusDetail" minOccurs="0"/>
        </sequence>
    </complexType>
    <element name="StatusCode" type="samlp:StatusCodeType"/>
    <complexType name="StatusCodeType">
        <sequence>
            <element ref="samlp:StatusCode" minOccurs="0"/>
        </sequence>
        <attribute name="Value" type="QName" use="required"/>
    </complexType>
    <element name="StatusMessage" type="string"/>
    <element name="StatusDetail" type="samlp:StatusDetailType"/>
    <complexType name="StatusDetailType">
        <sequence>
            <any namespace="##any" processContents="lax" minOccurs="0" maxOccurs="unbounded"/>
        </sequence>
    </complexType>
</schema>