From 74023ad339616936c5a2415f3b0347858c38df18 Mon Sep 17 00:00:00 2001 From: Erlend Hamnaberg <erlend@hamnaberg.net> Date: Fri, 30 Nov 2018 16:46:52 +0000 Subject: [PATCH] Saml 1.1 Validate support --- src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java | 208 ++++++++++ src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java | 48 ++ src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java | 12 src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd | 201 ++++++++++ src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java | 14 src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd | 132 ++++++ pom.xml | 7 src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java | 30 - src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java | 175 +++++++++ src/test/java/org/keycloak/protocol/cas/XMLValidator.java | 35 + src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java | 107 +++++ src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java | 150 ------- src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java | 1 13 files changed, 930 insertions(+), 190 deletions(-) diff --git a/pom.xml b/pom.xml index ff7af80..e70a469 100644 --- a/pom.xml +++ b/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> diff --git a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java b/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java index 2723079..fecd557 100644 --- a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocol.java +++ b/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"; diff --git a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java b/src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java index 861742a..80e5c28 100644 --- a/src/main/java/org/keycloak/protocol/cas/CASLoginProtocolService.java +++ b/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); diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java new file mode 100644 index 0000000..ecec352 --- /dev/null +++ b/src/main/java/org/keycloak/protocol/cas/endpoints/AbstractValidateEndpoint.java @@ -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; + } +} diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java new file mode 100644 index 0000000..3d7f3c3 --- /dev/null +++ b/src/main/java/org/keycloak/protocol/cas/endpoints/SamlValidateEndpoint.java @@ -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(); + } + } +} diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java index 291b74e..fa56d4f 100644 --- a/src/main/java/org/keycloak/protocol/cas/endpoints/ServiceValidateEndpoint.java +++ b/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); } diff --git a/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java b/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java index 9e84f0c..e83af7c 100644 --- a/src/main/java/org/keycloak/protocol/cas/endpoints/ValidateEndpoint.java +++ b/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); - } - - } } diff --git a/src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java b/src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java new file mode 100644 index 0000000..f5db51c --- /dev/null +++ b/src/main/java/org/keycloak/protocol/cas/representations/SamlResponseHelper.java @@ -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(); + } +} diff --git a/src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java b/src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java new file mode 100644 index 0000000..0ac2c1c --- /dev/null +++ b/src/test/java/org/keycloak/protocol/cas/SamlResponseTest.java @@ -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); + } +} diff --git a/src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java b/src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java index b8ebe5a..bed1f00 100644 --- a/src/test/java/org/keycloak/protocol/cas/ServiceResponseTest.java +++ b/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))); } } diff --git a/src/test/java/org/keycloak/protocol/cas/XMLValidator.java b/src/test/java/org/keycloak/protocol/cas/XMLValidator.java new file mode 100644 index 0000000..2a85231 --- /dev/null +++ b/src/test/java/org/keycloak/protocol/cas/XMLValidator.java @@ -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))); + } +} diff --git a/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd b/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd new file mode 100644 index 0000000..dee3a3e --- /dev/null +++ b/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-assertion-1.1.xsd @@ -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> diff --git a/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd b/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd new file mode 100644 index 0000000..8bea3a9 --- /dev/null +++ b/src/test/resources/org/keycloak/protocol/cas/oasis-sstc-saml-schema-protocol-1.1.xsd @@ -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> -- Gitblit v1.9.1