diff --git a/pom.xml b/pom.xml
index cd0667f..3d78db8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@
0.9.2
0.9.4
7.2.0
+ 5.2.0
7.6
@@ -56,6 +57,12 @@
+
+ net.shibboleth.ext
+ spring-extensions
+ ${spring-extensions.version}
+ test
+
uk.org.ukfederation
ukf-mda
@@ -78,6 +85,11 @@
spring-core
test
+
+ ${spring.groupId}
+ spring-test
+ test
+
diff --git a/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStage.java b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStage.java
new file mode 100644
index 0000000..b7b4088
--- /dev/null
+++ b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStage.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package uk.org.iay.incommon.mda.dom.saml.shib;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.w3c.dom.Element;
+
+import net.shibboleth.metadata.dom.AbstractDOMValidationStage;
+import net.shibboleth.metadata.pipeline.StageProcessingException;
+import net.shibboleth.metadata.validate.Validator;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.xml.AttributeSupport;
+import net.shibboleth.utilities.java.support.xml.ElementSupport;
+import uk.org.iay.incommon.mda.validate.ValidatorSequence;
+
+/**
+ * Stage to apply a collection of validators to Shibboleth shibmd:Scope
+ * values.
+ *
+ * A separate collection of validators is used for the case of the regexp
+ * attribute being true and false.
+ */
+public class ScopeValidationStage extends AbstractDOMValidationStage {
+
+ /** The sequence of validators to apply to regexp scopes. */
+ @Nonnull
+ private ValidatorSequence regexpValidators = new ValidatorSequence<>();
+
+ /**
+ * Set the sequence of validators to apply to each regexp scope.
+ *
+ * @param newValidators the list of validators to set
+ */
+ public void setRegexpValidators(@Nonnull final List> newValidators) {
+ regexpValidators.setValidators(newValidators);
+ }
+
+ /**
+ * Gets the sequence of validators being applied to each regexp scope.
+ *
+ * @return list of validators
+ */
+ @Nonnull
+ public List> getRegexpValidators() {
+ return regexpValidators.getValidators();
+ }
+
+ @Override
+ protected boolean applicable(@Nonnull final Element element) {
+ return ElementSupport.isElementNamed(element, ShibbolethMetadataSupport.SCOPE_NAME);
+ }
+
+ @Override
+ protected void visit(@Nonnull final Element element, @Nonnull final TraversalContext context)
+ throws StageProcessingException {
+ final String text = element.getTextContent();
+ final Boolean isRegexp = AttributeSupport.getAttributeValueAsBoolean(
+ AttributeSupport.getAttribute(element, ShibbolethMetadataSupport.REGEXP_ATTRIB_NAME));
+ if (isRegexp == null || !isRegexp.booleanValue()) {
+ // non-regexp Scope, apply normal validators
+ applyValidators(text, context);
+ } else {
+ // regexp Scope, apply secondary validators
+ regexpValidators.validate(text, context.getItem(), getId());
+ }
+ }
+
+ @Override
+ protected void doDestroy() {
+ regexpValidators.destroy();
+ regexpValidators = null;
+ super.doDestroy();
+ }
+
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ regexpValidators.setId(getId());
+ regexpValidators.initialize();
+ }
+
+}
diff --git a/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ShibbolethMetadataSupport.java b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ShibbolethMetadataSupport.java
new file mode 100644
index 0000000..3e89463
--- /dev/null
+++ b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/ShibbolethMetadataSupport.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package uk.org.iay.incommon.mda.dom.saml.shib;
+
+import javax.annotation.concurrent.ThreadSafe;
+import javax.xml.namespace.QName;
+
+/** Helper class for dealing with Shibboleth metadata. */
+@ThreadSafe
+public final class ShibbolethMetadataSupport {
+
+ /** Shibboleth metadata namespace URI. */
+ public static final String SHIBMD_NS = "urn:mace:shibboleth:metadata:1.0";
+
+ /** Default Shibboleth metadata namespace prefix. */
+ public static final String SHIBMD_PREFIX = "shibmd";
+
+ /** Scope element name. */
+ public static final QName SCOPE_NAME = new QName(SHIBMD_NS, "Scope", SHIBMD_PREFIX);
+
+ /** regexp attribute name. */
+ public static final QName REGEXP_ATTRIB_NAME = new QName("regexp");
+
+ /** Constructor. */
+ private ShibbolethMetadataSupport() {
+
+ }
+}
diff --git a/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/package-info.java b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/package-info.java
new file mode 100644
index 0000000..cc74a6a
--- /dev/null
+++ b/src/main/java/uk/org/iay/incommon/mda/dom/saml/shib/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Aggregator beans dealing with Shibboleth SAML metadata.
+ */
+package uk.org.iay.incommon.mda.dom.saml.shib;
diff --git a/src/main/resources/uk/org/iay/incommon/mda/beans.xml b/src/main/resources/uk/org/iay/incommon/mda/beans.xml
index 6939823..ebc34d3 100644
--- a/src/main/resources/uk/org/iay/incommon/mda/beans.xml
+++ b/src/main/resources/uk/org/iay/incommon/mda/beans.xml
@@ -31,6 +31,13 @@
+
+
+
+
diff --git a/src/test/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest.java b/src/test/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest.java
new file mode 100644
index 0000000..e9d631e
--- /dev/null
+++ b/src/test/java/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest.java
@@ -0,0 +1,172 @@
+
+package uk.org.iay.incommon.mda.dom.saml.shib;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.testng.AbstractTestNGSpringContextTests;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import net.shibboleth.metadata.ErrorStatus;
+import net.shibboleth.metadata.Item;
+import net.shibboleth.metadata.dom.DOMElementItem;
+import net.shibboleth.metadata.pipeline.Stage;
+import net.shibboleth.utilities.java.support.xml.AttributeSupport;
+import net.shibboleth.utilities.java.support.xml.ElementSupport;
+
+/**
+ * A litmus test for {@link ScopeValidationStage} involving a set of valid and invalid
+ * scope values, both for regular expression and plain cases.
+ *
+ * The configuration for the stage is taken from a Spring XML configuration file.
+ */
+@ContextConfiguration("ScopeValidationStageLitmusTest-config.xml")
+public class ScopeValidationStageLitmusTest extends AbstractTestNGSpringContextTests {
+
+ /** Build documents using this. */
+ private DocumentBuilder dBuilder;
+
+ /** {@link Stage} to run for each test. */
+ private Stage stage;
+
+ @BeforeClass
+ private void setUp() throws Exception {
+ final DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
+ dBuilder = dbFactory.newDocumentBuilder();
+ stage = makeStage();
+ }
+
+ /** Acquire the configured stage from the Spring context. */
+ private Stage makeStage() throws Exception {
+ @SuppressWarnings("unchecked")
+ final Stage stage = applicationContext.getBean("litmusTest", Stage.class);
+ stage.initialize();
+ return stage;
+ }
+
+ /** Build a shibmd:Scope {@link Element}. */
+ private Element buildScope(final Document document, final String value, final boolean isRegex) {
+ final Element element = ElementSupport.constructElement(document, ShibbolethMetadataSupport.SCOPE_NAME);
+ AttributeSupport.appendAttribute(element, ShibbolethMetadataSupport.REGEXP_ATTRIB_NAME,
+ isRegex ? "true" : "false");
+ element.setTextContent(value);
+ return element;
+ }
+
+ /** Build a {@link Document} containing an appropriate shibmd:Scope {@link Element}. */
+ private Document buildDocument(final String value, final boolean isRegex) {
+ final Document document = dBuilder.newDocument();
+ document.appendChild(buildScope(document, value, isRegex));
+ return document;
+ }
+
+ /** Run the test stage on a single {@link Item}. */
+ private List runTest(final Item item) throws Exception {
+ final Collection- > coll = new ArrayList<>();
+ coll.add(item);
+ stage.execute(coll);
+ final List errors = item.getItemMetadata().get(ErrorStatus.class);
+ return errors;
+ }
+
+ /** Test a value-regexp combination we expect to be accepted. */
+ private void good(final String value, final boolean isRegex) throws Exception {
+ final Item item = new DOMElementItem(buildDocument(value, isRegex));
+ final List errors = runTest(item);
+ Assert.assertEquals(errors.size(), 0, "expected no errors for '" + value + "'[" + isRegex + "]");
+ }
+
+ /** Test a non-regexp value we expect to be accepted. */
+ private void good(final String value) throws Exception {
+ good(value, false);
+ }
+
+ /** Test a regexp value we expect to be accepted. */
+ private void goodRegexp(final String value) throws Exception {
+ good(value, true);
+ }
+
+ /** Test a value-regexp combination we expect to be rejected. */
+ private void bad(final String value, final boolean isRegex, final String why) throws Exception {
+ final Item item = new DOMElementItem(buildDocument(value, isRegex));
+ final List errors = runTest(item);
+ Assert.assertEquals(errors.size(), 1, "expected an error for '" + value + "'[" + isRegex + "]");
+ final ErrorStatus error = errors.get(0);
+ final String message = error.getStatusMessage();
+ Assert.assertTrue(message.contains(why), "error '" + message + "' didn't contain '" + why + "'");
+ }
+
+ /** Test a non-regexp value we expect to be rejected. */
+ private void bad(final String value) throws Exception {
+ bad(value, false, "");
+ }
+
+ /** Test a non-regexp value we expect to be rejected. */
+ private void bad(final String value, final String why) throws Exception {
+ bad(value, false, why);
+ }
+
+ /** Test a regexp value we expect to be rejected. */
+ private void badRegexp(final String value, final String why) throws Exception {
+ bad(value, true, why);
+ }
+
+ /** Test a regexp value we expect to be rejected. */
+ private void badRegexp(final String value) throws Exception {
+ bad(value, true, "");
+ }
+
+ @Test
+ public void litmusTests() throws Exception {
+ good("example.org");
+ bad("", "empty");
+ bad(" ");
+ bad(" ");
+ bad(" example.org", "white space");
+ bad("example.org ", "white space");
+ bad("EXAMPLE.ORG", "upper-case");
+ bad("example**.org", "scope is not a valid domain name: example**.org");
+ bad("uk", "scope is a public suffix");
+ bad("ac.uk", "scope is a public suffix");
+ bad("random.nonsense", "scope is not under a public suffix");
+ good("example.ac.uk");
+
+ badRegexp("", "empty");
+ badRegexp(" ");
+ badRegexp(" ");
+ badRegexp("aaaa$", "does not start with an anchor ('^')");
+ badRegexp("^aaaa", "does not end with an anchor ('$')");
+ goodRegexp("^([a-zA-Z0-9-]{1,63}\\.){0,2}vho\\.aaf\\.edu\\.au$");
+ // don't use literal .s
+ badRegexp("^([a-zA-Z0-9-]{1,63}.){0,2}vho.aaf.edu.au$", "does not end with a literal tail");
+ // bad literal tail: no public suffix
+ badRegexp("^([a-zA-Z0-9-]{1,63}\\.){0,2}vho\\.aaf\\.edu\\.nopublic$", "literal tail is not under a public suffix");
+ // bad literal tail: is a public suffix
+ badRegexp("^.*\\.ac\\.uk$", "literal tail is a public suffix");
+ // bad literal tail: upper case
+ badRegexp("^([a-zA-Z0-9-]{1,63}\\.){0,2}vho\\.aaf\\.edu\\.AU$", "includes upper-case characters");
+
+ // UK federation examples
+ goodRegexp("^.+\\.atomwide\\.com$");
+ goodRegexp("^.+\\.856\\.eng\\.ukfederation\\.org\\.uk$");
+ goodRegexp("^.+\\.scot\\.nhs\\.uk$");
+ goodRegexp("^.+\\.login\\.groupcall\\.com$");
+ goodRegexp("^.+\\.logintestingthirks\\.groupcall\\.com$");
+ goodRegexp("^.+\\.logintest\\.me\\.e2bn\\.org$");
+ goodRegexp("^.+\\.loginstaging\\.groupcall\\.com$");
+ goodRegexp("^.+\\.identityfor\\.co\\.uk$");
+ goodRegexp("^.+\\.rmunify\\.com$");
+
+ // eduGAIN examples
+
+ }
+}
diff --git a/src/test/resources/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest-config.xml b/src/test/resources/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest-config.xml
new file mode 100644
index 0000000..9588cdd
--- /dev/null
+++ b/src/test/resources/uk/org/iay/incommon/mda/dom/saml/shib/ScopeValidationStageLitmusTest-config.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+