[java-plugin-shibd-saml] branch main updated: Merge state tracking redesign from dev branch.

Codeberg noreply at shibboleth.net
Wed May 6 15:10:23 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch main
in repository java-plugin-shibd-saml.

View the commit online:
https://codeberg.org/Shibboleth/java-plugin-shibd-saml/commit/8500ad341e1291fae7141a67f2391cbb5a75aece

The following commit(s) were added to refs/heads/main by this push:
     new 8500ad3  Merge state tracking redesign from dev branch.
8500ad3 is described below

commit 8500ad341e1291fae7141a67f2391cbb5a75aece
Author: Scott Cantor <scott at restingparrotsoftware.com>
AuthorDate: Wed May 6 11:06:13 2026 -0400

    Merge state tracking redesign from dev branch.
---
 .../shibboleth/sp/saml/saml2/SAMLStateData.java    | 112 ++++
 .../context/navigate/RequestIDLookupFunction.java  |  43 ++
 .../saml/saml2/context/navigate/package-info.java  |  18 +
 .../config/BrowserSSOProfileConfiguration.java     |  19 +
 .../idp/flows/sp/consumer/saml2/saml2-beans.xml    |  52 +-
 .../idp/flows/sp/consumer/saml2/saml2-flow.xml     |   5 +-
 .../idp/flows/sp/initiator/saml2/saml2-beans.xml   |  18 -
 .../idp/flows/sp/initiator/saml2/saml2-flow.xml    |   2 +-
 .../flows/sp/logout/initiator/saml2/saml2-flow.xml |   1 +
 .../shibboleth/idp/flows/sp/saml2-common-beans.xml |   5 +
 .../flows/saml2/SAML2LogoutInitiatorFlowTest.java  |  83 +--
 .../flows/saml2/SAML2SessionInitiatorFlowTest.java |  73 +--
 .../flows/saml2/SAML2TokenConsumerFlowTest.java    | 564 +++++++++++++++------
 ...MLEnvironmentApplicationContextInitializer.java |   1 +
 sp-saml-impl/pom.xml                               |   5 +
 .../impl/CheckDestinationAndIssuerHandler.java     |  93 ++++
 .../sp/saml/saml2/messaging/impl/package-info.java |  18 +
 .../impl/BrowserSSOProfileConfiguration.java       |  33 +-
 .../saml/saml2/profile/impl/AddAuthnRequest.java   |  83 ++-
 .../saml/saml2/profile/impl/AddLogoutRequest.java  |  58 ++-
 .../saml2/profile/impl/PrepareAgentResponse.java   |   8 +
 .../saml2/profile/impl/PreserveRelayState.java     |  37 ++
 .../impl/ProcessAssertionsForAuthentication.java   | 385 ++++++++++++++
 23 files changed, 1397 insertions(+), 319 deletions(-)

diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/SAMLStateData.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/SAMLStateData.java
new file mode 100644
index 0000000..192db7f
--- /dev/null
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/SAMLStateData.java
@@ -0,0 +1,112 @@
+/*
+ * 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 net.shibboleth.sp.saml.saml2;
+
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.saml.saml2.core.RequestedAuthnContext;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.MoreObjects;
+
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.sp.state.StateData;
+
+/**
+ * SAML-specific subclass adding additional state.
+ */
+public class SAMLStateData extends StateData {
+
+    /** SAML request ID to track. */
+    @Nullable String requestID;
+    
+    /** SAML {@link RequestedAuthnContext} comparison operator. */
+    @Nullable String authnContextOperator;
+
+    /**
+     * Get the identifier of the request message.
+     * 
+     * @return the request ID
+     */
+    @JsonProperty("req_id")
+    @Nullable public String getRequestID() {
+        return requestID;
+    }
+
+    /**
+     * Set the identifier of request message
+     * 
+     * @param id the ID to set
+     * 
+     * @return the updated object
+     */
+    @Nonnull public SAMLStateData setRequestID(@Nullable final String id) {
+        requestID = id;
+        return this;
+    }
+    
+    /**
+     * Get the {@link RequestedAuthnContext} comparison operator used in the request.
+     * 
+     * @return comparison operator
+     */
+    @JsonProperty("operator")
+    @Nullable public String getAuthnContextOperator() {
+        return authnContextOperator;
+    }
+    
+    /**
+     * Set the {@link RequestedAuthnContext} comparison operator used in the request.
+     * 
+     * @param op comparison operator
+     * 
+     * @return the updated object
+     */
+    public SAMLStateData setAuthnContextOperator(@Nullable final String op) {
+        authnContextOperator = StringSupport.trimOrNull(op);
+        return this;
+    }
+ 
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), requestID, authnContextOperator);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        if (!super.equals(obj)) {
+            return false;
+        }
+        
+        final SAMLStateData other = (SAMLStateData) obj;
+        return Objects.equals(requestID, other.requestID)
+                && Objects.equals(authnContextOperator, other.authnContextOperator);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("requestID", requestID)
+                .add("authnContextOperator", authnContextOperator)
+                .toString();
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/RequestIDLookupFunction.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/RequestIDLookupFunction.java
new file mode 100644
index 0000000..941aded
--- /dev/null
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/RequestIDLookupFunction.java
@@ -0,0 +1,43 @@
+/*
+ * 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 net.shibboleth.sp.saml.saml2.context.navigate;
+
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ContextDataLookupFunction;
+
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+import net.shibboleth.sp.state.StateData;
+
+/**
+ * Lookup strategy for accessing request ID via {@link StateDataContext#getStateData()} and
+ * {@link SAMLStateData#getRequestID()}.
+ */
+public class RequestIDLookupFunction implements ContextDataLookupFunction<StateDataContext,String> {
+
+    /** {@inheritDoc} */
+    @Nullable public String apply(@Nullable final StateDataContext input) {
+        if (input != null) {
+            final StateData data = input.getStateData();
+            if (data instanceof SAMLStateData saml) {
+                return saml.getRequestID();
+            }
+        }
+        
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/package-info.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/package-info.java
new file mode 100644
index 0000000..f3eccb3
--- /dev/null
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/navigate/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.
+ */
+
+/**
+ * Functions for navigating SAML-specific context information.
+ */
+package net.shibboleth.sp.saml.saml2.context.navigate;
\ No newline at end of file
diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/BrowserSSOProfileConfiguration.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/BrowserSSOProfileConfiguration.java
index d1f7a47..a0c9bf2 100644
--- a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/BrowserSSOProfileConfiguration.java
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/BrowserSSOProfileConfiguration.java
@@ -27,12 +27,14 @@ import net.shibboleth.shared.annotation.ConfigurationSetting;
 import net.shibboleth.shared.annotation.constraint.NonnullElements;
 import net.shibboleth.shared.annotation.constraint.NotLive;
 import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.sp.state.StateManager;
 
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.saml.common.xml.SAMLConstants;
 import org.opensaml.saml.saml2.core.Attribute;
 import org.opensaml.saml.saml2.core.AuthnContextClassRef;
 import org.opensaml.saml.saml2.core.AuthnRequest;
+import org.opensaml.saml.saml2.core.RequestedAuthnContext;
 import org.opensaml.saml.saml2.core.SubjectConfirmationData;
 
 /** Configuration support for SP SAML 2.0 Browser SSO. */
@@ -90,6 +92,23 @@ public interface BrowserSSOProfileConfiguration extends SAMLArtifactConsumerProf
     @ConfigurationSetting(name="authnContextClassRefs")
     @Nonnull @NonnullElements @NotLive @Unmodifiable List<String> getAuthnContextClassRefs(
             @Nullable final ProfileRequestContext profileRequestContext);
+    
+    /**
+     * Get whether to validate the incoming assertions' {@link AuthnContextClassRef} against
+     * any {@link RequestedAuthnContext} included in the original request.
+     * 
+     * <p>This leverages both the Hub's {@link StateManager} to recover the requested values
+     * and the IdP's existing machibery for evaluating the information in the case of inexact
+     * comparison operators.</p>
+     * 
+     * <p>Defaults to true.</p>
+     * 
+     * @param profileRequestContext profile request context
+     * 
+     * @return whether to cross check the resulting ACRs
+     */
+    @ConfigurationSetting(name="validateAuthnContextClassRefs")
+    boolean isValidateAuthnContextClassRefs(@Nullable final ProfileRequestContext profileRequestContext);
 
     /**
      * Get the name identifier format to require via the SAML request.
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-beans.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-beans.xml
index b502bab..404171d 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-beans.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-beans.xml
@@ -12,10 +12,11 @@
 
     <import resource="classpath:/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml" />
 
-    <bean id="MapStateTokenToResource"
-        class="net.shibboleth.sp.profile.impl.MapStateTokenToResource" scope="prototype"
-        p:createOutputObjects="true"
-        p:stateTokenLookupStrategy-ref="RelayStateLookup" />
+    <bean id="RecoverStateData"
+        class="net.shibboleth.sp.profile.impl.RecoverStateData" scope="prototype"
+        p:stateTokenLookupStrategy-ref="RelayStateLookup"
+        p:stateDataClass="net.shibboleth.sp.saml.saml2.SAMLStateData"
+        p:createOutputObjects="true" />
 
     <bean id="RelayStateLookup" parent="shibboleth.Functions.Compose">
         <constructor-arg name="g">
@@ -41,6 +42,24 @@
         </property>
     </bean>
 
+    <bean id="CheckDestinationAndIssuerHandler" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
+            c:executionDirection="INBOUND">
+        <constructor-arg>
+            <bean class="org.opensaml.messaging.handler.impl.BasicMessageHandlerChain">
+                <property name="handlers">
+                    <list>
+                        <bean class="net.shibboleth.sp.saml.saml2.messaging.impl.CheckDestinationAndIssuerHandler" scope="prototype"
+                            p:checkDuringInit="false"
+                            p:httpServletRequestSupplier-ref="shibboleth.RemotedHttpServletRequestSupplier" />
+                    </list>
+                </property>
+             </bean>
+        </constructor-arg>
+        <property name="errorEvent">
+            <util:constant static-field="org.opensaml.profile.action.EventIds.INVALID_MESSAGE" />
+        </property>
+    </bean>
+
     <bean id="InboundEntityIDLookup" parent="shibboleth.Functions.Compose">
         <constructor-arg name="g">
             <bean class="org.opensaml.saml.common.messaging.context.navigate.SAMLEntityIDFunction" />
@@ -51,12 +70,6 @@
         </constructor-arg>
     </bean>
 
-    <bean id="ProcessCorrelationCookie" class="net.shibboleth.sp.profile.impl.ProcessCorrelationCookie" scope="prototype"
-        p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
-        p:cookiePrefix="%{sp.correlation.cookiePrefix:__Host-shibsp_req_}"
-        p:createOutputObjects="true"
-        p:stateTokenLookupStrategy-ref="RelayStateLookup" />
-        
     <bean id="HandleResponse" class="net.shibboleth.sp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
             c:executionDirection="INBOUND">
         <constructor-arg>
@@ -149,19 +162,11 @@
         <property name="inResponseTo">
             <bean parent="shibboleth.Functions.Compose">
                 <constructor-arg name="g">
-                    <bean class="net.shibboleth.sp.profile.context.navigate.MessageCorrelationIDLookupFunction" />
+                    <bean class="net.shibboleth.sp.saml.saml2.context.navigate.RequestIDLookupFunction" />
                 </constructor-arg>
                 <constructor-arg name="f">
-                    <bean parent="shibboleth.Functions.Compose">
-                        <constructor-arg name="g">
-                            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
-                                c:type="#{ T(net.shibboleth.sp.context.TokenConsumerContext) }" />
-                        </constructor-arg>
-                        <constructor-arg name="f">
-                            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
-                                c:type="#{ T(net.shibboleth.sp.context.AgentRequestContext) }" />
-                        </constructor-arg>
-                    </bean>
+                    <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
+                        c:type="#{ T(net.shibboleth.sp.context.StateDataContext) }" />
                 </constructor-arg>
             </bean>
         </property>
@@ -223,8 +228,9 @@
     <bean id="SAMLStatementConsumer" class="net.shibboleth.sp.saml.saml2.profile.impl.SAMLTokenContextConsumer" />
 
     <bean id="ProcessAssertionsForAuthentication"
-            class="net.shibboleth.idp.saml.saml2.profile.impl.ProcessAssertionsForAuthentication" scope="prototype"
-            p:sAMLConsumer-ref="SAMLStatementConsumer">
+            class="net.shibboleth.sp.saml.saml2.profile.impl.ProcessAssertionsForAuthentication" scope="prototype"
+            p:sAMLConsumer-ref="SAMLStatementConsumer"
+            p:principalEvalPredicateFactoryRegistry-ref="shibboleth.AuthnComparisonRegistry">
         <property name="responseResolver">
             <bean parent="shibboleth.Functions.Compose">
                 <constructor-arg name="g">
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-flow.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-flow.xml
index c4f89e8..1a05992 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-flow.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/consumer/saml2/saml2-flow.xml
@@ -8,12 +8,13 @@
         <evaluate expression="DecodeMessage" />
 <!--        <evaluate expression="PostDecodePopulateAuditContext" />-->
 
-        <evaluate expression="MapStateTokenToResource" />
+        <evaluate expression="RecoverStateData" />
         
         <evaluate expression="CheckMessageVersion" />
         <evaluate expression="HandleNoPassive" />
-        <evaluate expression="ProcessCorrelationCookie" />
+        
         <evaluate expression="SAMLProtocolAndRole" />
+        <evaluate expression="CheckDestinationAndIssuerHandler" />
         <evaluate expression="SAMLMetadataLookup" />
 
         <evaluate expression="InitializeRelyingPartyContextFromSAMLPeer" />
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-beans.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-beans.xml
index 0a111f9..3e73ab2 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-beans.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-beans.xml
@@ -44,22 +44,4 @@
         </property>
     </bean>
 
-    <bean id="IssueCorrelationCookie" class="net.shibboleth.sp.profile.impl.IssueCorrelationCookie" scope="prototype"
-        p:cookieManager-ref="shibboleth.sp.RemotedCookieManager"
-        p:cookiePrefix="#{'%{sp.correlation.cookiePrefix:__Host-shibsp_req_}'.trim()}"
-        p:createOutputObjects="true"
-        p:errorFatal="%{sp.stateToken.errorsFatal:false}"
-        p:requestIDLookupStrategy-ref="RequestIDStrategy" />
-
-    <bean id="RequestIDStrategy" parent="shibboleth.Functions.Compose">
-        <constructor-arg name="g">
-            <bean class="org.opensaml.saml.common.messaging.context.navigate.SAMLMessageInfoContextIDFunction" />
-        </constructor-arg>
-        <constructor-arg name="f">
-            <bean parent="shibboleth.Functions.Compose"
-                c:g-ref="shibboleth.ChildLookupOrCreate.SAMLMessageInfoContext"
-                c:f-ref="shibboleth.MessageContextLookup.Outbound" />
-        </constructor-arg>
-    </bean>
-
 </beans>
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-flow.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-flow.xml
index a41be49..a5a0bb6 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-flow.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/initiator/saml2/saml2-flow.xml
@@ -24,7 +24,7 @@
         <evaluate expression="EncryptNameIDs" />
 
         <evaluate expression="HandleOutboundMessage" />
-        <evaluate expression="IssueCorrelationCookie" />
+        <evaluate expression="PreserveRelayState" />
         <evaluate expression="PreservePostData" />
         <evaluate expression="EncodeMessage" />
         <evaluate expression="'proceed'" />
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/initiator/saml2/saml2-flow.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/initiator/saml2/saml2-flow.xml
index ec8dfb3..783d3d8 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/initiator/saml2/saml2-flow.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/logout/initiator/saml2/saml2-flow.xml
@@ -24,6 +24,7 @@
         <evaluate expression="EncryptNameIDs" />
 
         <evaluate expression="HandleOutboundMessage" />
+        <evaluate expression="PreserveRelayState" />
         <evaluate expression="EncodeMessage" />
         <evaluate expression="'proceed'" />
 
diff --git a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
index a56652c..d996699 100644
--- a/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
+++ b/sp-saml-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/sp/saml2-common-beans.xml
@@ -165,6 +165,11 @@
         </property>
     </bean>
 
+    <bean id="PreserveRelayState"
+        class="net.shibboleth.sp.saml.saml2.profile.impl.PreserveRelayState" scope="prototype"
+        p:createOutputObjects="true"
+        p:errorFatal="%{sp.stateToken.errorsFatal:false}" />
+
     <bean id="messageEncoderFactory" class="net.shibboleth.idp.saml.profile.impl.SpringAwareMessageEncoderFactory" />
 
     <bean id="EncodeMessage" class="net.shibboleth.sp.profile.impl.EncodeMessage" scope="prototype"
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutInitiatorFlowTest.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutInitiatorFlowTest.java
index d65f684..3451adc 100644
--- a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutInitiatorFlowTest.java
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2LogoutInitiatorFlowTest.java
@@ -209,30 +209,6 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         assertFlowExecutionOutcome(result.getOutcome());
         assertOutputMessageEvent(result, AuthnEventIds.NO_POTENTIAL_FLOW);
     }    
-    
-    /**
-     * Test simple success case with preset relay state.
-     * 
-     * @throws IOException 
-     * @throws MessageDecodingException 
-     */
-    @Test
-    public void testSimpleWithState() throws IOException, MessageDecodingException {
-        setDefaultAuth();
-        
-        final DDF input = new DDF(null).structure();
-        input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(SESSION_DATA);
-        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
-        input.addmember(SPConstants.STATE).string("foostate");
-        setApplicationRequest(APPLICATION_ID, input);
-
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        
-        assertOutputMessageSuccess(result);
-        validateOutputMessage(result, "12345", false);
-    }
 
     /**
      * Test simple success case with encryption enabled.
@@ -247,7 +223,7 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         final DDF input = new DDF(null).structure();
         input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(SESSION_DATA);
         input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
-        input.addmember(SPConstants.STATE).string("foostate");
+        input.addmember(SPConstants.TARGET).unsafe_string(RESOURCE_URL);
         setApplicationRequest("logout-encryption", input);
 
         final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
@@ -257,31 +233,6 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         assertOutputMessageSuccess(result);
         validateOutputMessage(result, "12345", true);
     }
-    
-    /**
-     * Test simple success case with preset relay state.
-     * 
-     * @throws IOException 
-     * @throws MessageDecodingException 
-     */
-    @Test
-    public void testSimpleWithStateNoIndex() throws IOException, MessageDecodingException {
-        setDefaultAuth();
-        
-        final DDF input = new DDF(null).structure();
-        input.addmember(ConsumerConstants.SESSION_OPAQUE).addmember(PrepareAgentResponse.NAMEID_PARAM).string(SESSION_DATA_NO_INDEX);
-        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
-        input.addmember(SPConstants.STATE).string("foostate");
-        setApplicationRequest(APPLICATION_ID, input);
-
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        
-        assertOutputMessageSuccess(result);
-        validateOutputMessage(result, null, false);
-    }
-    
 
     /**
      * Test simple success case with computed relay state.
@@ -290,7 +241,7 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
      * @throws MessageDecodingException 
      */
     @Test
-    public void testSimpleWithoutState() throws IOException, MessageDecodingException {
+    public void testSimple() throws IOException, MessageDecodingException {
         setDefaultAuth();
         
         final DDF input = new DDF(null).structure();
@@ -324,9 +275,9 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         final ProfileRequestContext prc = retrieveProfileRequestContext(result);
         assert prc != null;
         final AgentRequestContext arc = prc.ensureSubcontext(AgentRequestContext.class);
-        final DDF input = arc.getInput();
         final DDF output = arc.getOutput();
-
+        final DDF input = arc.getInput();
+        assert input != null;
         assert output != null;
         Assert.assertTrue(output.isstruct());
         final DDF http = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME);
@@ -336,8 +287,7 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         final byte[] redirect = http.getmember(RemotedHttpServletResponse.REDIRECT).unsafe_string();
         if (redirect != null) {
             final String redirectURL = new String(redirect, StandardCharsets.UTF_8);
-            final SAMLObject saml = decodeRedirect(redirectURL,
-                    input != null ? input.getmember(SPConstants.STATE).string() : null);
+            final SAMLObject saml = decodeRedirect(redirectURL);
             assert saml instanceof LogoutRequest;
             logoutRequest = (LogoutRequest) saml;
             Assert.assertTrue(redirectURL.startsWith(logoutRequest.getDestination()));
@@ -348,8 +298,6 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
             final Object saml = prc.ensureOutboundMessageContext().ensureMessage();
             assert saml instanceof LogoutRequest;
             logoutRequest = (LogoutRequest) saml;
-            Assert.assertEquals(SAMLBindingSupport.getRelayState(prc.ensureOutboundMessageContext()),
-                    input != null ? input.getmember(SPConstants.STATE).string() : null);
         }
         
         assert logoutRequest != null;
@@ -380,6 +328,23 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         assert exts != null;
         Assert.assertEquals(exts.getUnknownXMLObjects(Asynchronous.DEFAULT_ELEMENT_NAME).size(), 1);
         
+        boolean foundCorrelationCookie = false;
+        for (final DDF header : http.getmember(RemotedHttpServletResponse.HEADERS)) {
+            if ("Set-Cookie".equals(header.name())) {
+                final String cookie = header.string();
+                assert cookie != null;
+                // TODO: Ideally we would extract the cookie value from the header and be able to
+                // test against a base64'd JSON string, but that's a fair bit of work.
+                if (cookie.startsWith("__Host-" + "shibsp_state_" + input.getmember(SPConstants.APPLICATION).string() + '_')) {
+                    Assert.assertTrue(
+                            cookie.startsWith("__Host-shibsp_state_" + input.getmember(SPConstants.APPLICATION).string() + '_'
+                                    + SAMLBindingSupport.getRelayState(prc.ensureOutboundMessageContext()) + '='));
+                    foundCorrelationCookie = true;
+                }
+            }
+        }
+        Assert.assertTrue(foundCorrelationCookie);
+
         return logoutRequest;
     }
 
@@ -388,12 +353,11 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
      * Decodes a SAML message encoded via HTTP-Redirect binding.
      * 
      * @param url the encoded redirect
-     * @param relayState RelayState to check for
      * 
      * @return decoded message
      * @throws MessageDecodingException 
      */
-    @Nonnull protected SAMLObject decodeRedirect(@Nullable final String url, @Nullable final String relayState)
+    @Nonnull protected SAMLObject decodeRedirect(@Nullable final String url)
             throws MessageDecodingException {
         final MockHttpServletRequest mock = new MockHttpServletRequest("GET", url);
         final int index = url != null ? url.indexOf('?') : -1;
@@ -424,7 +388,6 @@ public class SAML2LogoutInitiatorFlowTest extends AbstractSPFlowTest {
         decoder.destroy();
         
         if (mc != null && mc.getMessage() instanceof SAMLObject saml) {
-            Assert.assertEquals(SAMLBindingSupport.getRelayState(mc), relayState);
             return saml;
         }
         throw new MessageDecodingException("No message, or incorrect type.");
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2SessionInitiatorFlowTest.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2SessionInitiatorFlowTest.java
index 07f24b2..ad1de05 100644
--- a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2SessionInitiatorFlowTest.java
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2SessionInitiatorFlowTest.java
@@ -57,7 +57,6 @@ import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
 import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
 import net.shibboleth.sp.profile.InitiatorConstants;
 import net.shibboleth.sp.profile.SPConstants;
-import net.shibboleth.sp.profile.impl.IssueCorrelationCookie;
 import net.shibboleth.sp.saml.saml2.profile.SAML2InitiatorConstants;
 
 /**
@@ -194,37 +193,6 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
         assertFalse(req.isPassive());
     }
     
-    /**
-     * Test simple success case with preset relay state.
-     * 
-     * @throws IOException 
-     * @throws MessageDecodingException 
-     */
-    @Test
-    public void testSimpleWithState() throws IOException, MessageDecodingException {
-        setDefaultAuth();
-        
-        final DDF input = new DDF(null).structure();
-        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
-        input.addmember(InitiatorConstants.RESPONSE_URL).string(RESPONSE_URL);
-        input.addmember(SPConstants.STATE).string("foostate");
-        setApplicationRequest(APPLICATION_ID, input);
-
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        
-        assertOutputMessageSuccess(result);
-        final AuthnRequest req = validateOutputMessage(result, null);
-        Assert.assertEquals(req.getAssertionConsumerServiceURL(), RESPONSE_URL);
-        Assert.assertEquals(req.getProtocolBinding(), SAMLConstants.SAML2_POST_BINDING_URI);
-        Assert.assertNull(req.getSubject());
-        Assert.assertNull(req.getRequestedAuthnContext());
-        Assert.assertNull(req.getScoping());
-        assertFalse(req.isForceAuthn());
-        assertFalse(req.isPassive());
-    }
-    
     /**
      * Test legacy response URL success case picking first URL.
      * 
@@ -579,14 +547,14 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
      * 
      * @throws MessageDecodingException
      */
-    @Nonnull private AuthnRequest validateOutputMessage(@Nonnull final FlowExecutionResult result, @Nullable final String format)
-            throws MessageDecodingException {
+    @Nonnull private AuthnRequest validateOutputMessage(@Nonnull final FlowExecutionResult result,
+            @Nullable final String format) throws MessageDecodingException {
         final ProfileRequestContext prc = retrieveProfileRequestContext(result);
         assert prc != null;
         final AgentRequestContext arc = prc.ensureSubcontext(AgentRequestContext.class);
         final DDF input = arc.getInput();
         final DDF output = arc.getOutput();
-
+        assert input != null;
         assert output != null;
         Assert.assertTrue(output.isstruct());
         final DDF http = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME);
@@ -596,8 +564,7 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
         final byte[] redirect = http.getmember(RemotedHttpServletResponse.REDIRECT).unsafe_string();
         if (redirect != null) {
             final String redirectURL = new String(redirect, StandardCharsets.UTF_8);
-            final SAMLObject saml = decodeRedirect(redirectURL,
-                    input != null ? input.getmember(SPConstants.STATE).string() : null);
+            final SAMLObject saml = decodeRedirect(redirectURL);
             assert saml instanceof AuthnRequest;
             authnRequest = (AuthnRequest) saml;
             Assert.assertTrue(redirectURL.startsWith(authnRequest.getDestination()));
@@ -608,8 +575,6 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
             final Object saml = prc.ensureOutboundMessageContext().ensureMessage();
             assert saml instanceof AuthnRequest;
             authnRequest = (AuthnRequest) saml;
-            Assert.assertEquals(SAMLBindingSupport.getRelayState(prc.ensureOutboundMessageContext()),
-                    input != null ? input.getmember(SPConstants.STATE).string() : null);
         }
         
         assert authnRequest != null;
@@ -619,22 +584,22 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
         assert issuer != null;
         Assert.assertEquals(issuer.getValue(), ISSUER);
         
-        if (input != null) {
-            boolean foundCorrelationCookie = false;
-            for (final DDF header : http.getmember(RemotedHttpServletResponse.HEADERS)) {
-                if ("Set-Cookie".equals(header.name())) {
-                    final String cookie = header.string();
-                    assert cookie != null;
-                    if (cookie.startsWith("__Host-" + IssueCorrelationCookie.DEFAULT_COOKIE_PREFIX)) {
-                        Assert.assertEquals(cookie,
-                                "__Host-shibsp_req_" + input.getmember(SPConstants.STATE).string() + '=' + authnRequest.getID()
-                                    + "; HttpOnly; Path=/; SameSite=None; Secure");
-                        foundCorrelationCookie = true;
-                    }
+        boolean foundCorrelationCookie = false;
+        for (final DDF header : http.getmember(RemotedHttpServletResponse.HEADERS)) {
+            if ("Set-Cookie".equals(header.name())) {
+                final String cookie = header.string();
+                assert cookie != null;
+                // TODO: Ideally we would extract the cookie value from the header and be able to
+                // test against a base64'd JSON string, but that's a fair bit of work.
+                if (cookie.startsWith("__Host-" + "shibsp_state_" + input.getmember(SPConstants.APPLICATION).string() + '_')) {
+                    Assert.assertTrue(
+                            cookie.startsWith("__Host-shibsp_state_" + input.getmember(SPConstants.APPLICATION).string() + '_'
+                                    + SAMLBindingSupport.getRelayState(prc.ensureOutboundMessageContext()) + '='));
+                    foundCorrelationCookie = true;
                 }
             }
-            Assert.assertTrue(foundCorrelationCookie);
         }
+        Assert.assertTrue(foundCorrelationCookie);
         
         final NameIDPolicy pol = authnRequest.getNameIDPolicy();
         assert pol != null;
@@ -649,12 +614,11 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
      * Decodes a SAML message encoded via HTTP-Redirect binding.
      * 
      * @param url the encoded redirect
-     * @param relayState RelayState to check for
      * 
      * @return decoded message
      * @throws MessageDecodingException 
      */
-    @Nonnull protected SAMLObject decodeRedirect(@Nullable final String url, @Nullable final String relayState)
+    @Nonnull protected SAMLObject decodeRedirect(@Nullable final String url)
             throws MessageDecodingException {
         final MockHttpServletRequest mock = new MockHttpServletRequest("GET", url);
         final int index = url != null ? url.indexOf('?') : -1;
@@ -685,7 +649,6 @@ public class SAML2SessionInitiatorFlowTest extends AbstractSPFlowTest {
         decoder.destroy();
         
         if (mc != null && mc.getMessage() instanceof SAMLObject saml) {
-            Assert.assertEquals(SAMLBindingSupport.getRelayState(mc), relayState);
             return saml;
         }
         throw new MessageDecodingException("No message, or incorrect type.");
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2TokenConsumerFlowTest.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2TokenConsumerFlowTest.java
index a589534..b493276 100644
--- a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2TokenConsumerFlowTest.java
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/SAML2TokenConsumerFlowTest.java
@@ -20,6 +20,7 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 
 import javax.annotation.Nonnull;
@@ -56,13 +57,23 @@ import org.opensaml.xmlsec.signature.support.SignatureException;
 import org.opensaml.xmlsec.signature.support.SignatureSupport;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.web.WebAppConfiguration;
 import org.springframework.webflow.executor.FlowExecutionResult;
 import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
 import net.shibboleth.idp.authn.AuthnEventIds;
 import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.idp.spring.IdPPropertiesApplicationContextInitializer;
@@ -72,17 +83,32 @@ import net.shibboleth.shared.codec.DecodingException;
 import net.shibboleth.shared.codec.EncodingException;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.collection.Pair;
+import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.net.CookieManager;
 import net.shibboleth.shared.net.URISupport;
+import net.shibboleth.shared.net.CookieManager.SameSiteValue;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.resolver.CriteriaSet;
+import net.shibboleth.shared.resolver.ResolverException;
 import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.service.ReloadableService;
+import net.shibboleth.shared.service.ServiceableComponent;
 import net.shibboleth.shared.xml.XMLParserException;
+import net.shibboleth.sp.Agent;
+import net.shibboleth.sp.AgentCriterion;
+import net.shibboleth.sp.AgentResolver;
+import net.shibboleth.sp.Application;
 import net.shibboleth.sp.context.AgentRequestContext;
 import net.shibboleth.sp.ddf.DDF;
 import net.shibboleth.sp.flows.AbstractSPFlowTest;
 import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
 import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
 import net.shibboleth.sp.profile.ConsumerConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
 import net.shibboleth.sp.saml.saml2.profile.impl.PrepareAgentResponse;
+import net.shibboleth.sp.state.StateData;
+import net.shibboleth.sp.state.impl.CookieStateManager;
 
 /**
  * Unit test for the SP session-initiator flow.
@@ -116,18 +142,85 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
     /** ACS URL. */
     @Nonnull public static final String RESOURCE_URL = "https://sp.example.org/secure";
 
+    @Autowired
+    @Qualifier("shibboleth.sp.AgentResolver")
+    protected ReloadableService<AgentResolver> agentResolver;
+    
     @Autowired
     @Qualifier("shibboleth.SessionIDGenerator")
     protected IdentifierGenerationStrategy idGenerator;
     
     /** Dummy signing key. */
-    @Autowired @Qualifier("dummy.idp.Credential") protected Credential idpCredential;
+    @Autowired
+    @Qualifier("dummy.idp.Credential")
+    protected Credential idpCredential;
 
+    // Used to create state cookies for subsequent inclusion in mock requests to flow.
+    
+    private CookieManager cookieManager;
+    private CookieStateManager stateManager;
+    // Renamed to avoid stomping on base class objects.
+    private MockHttpServletRequest request2;
+    private MockHttpServletResponse response2;
+    
     /** Constructor. */
     public SAML2TokenConsumerFlowTest() {
         super(FLOW_ID);
     }
     
+    /**
+     * Set up state manager.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeClass
+    public void beforeClass() throws ComponentInitializationException {
+        cookieManager = new CookieManager();
+        cookieManager.setCookiePath("/");
+        cookieManager.setSameSite(SameSiteValue.None);
+        cookieManager.setCookieLimit(10);
+        cookieManager.setHttpServletRequestSupplier(new NonnullSupplier<HttpServletRequest>() {
+            @Nonnull public HttpServletRequest get() {
+                assert request2 != null;
+                return request2;
+            }
+        });
+        cookieManager.setHttpServletResponseSupplier(new NonnullSupplier<HttpServletResponse>() {
+            @Nonnull public HttpServletResponse get() {
+                assert response2 != null;
+                return response2;
+            }
+        });
+        cookieManager.initialize();
+        
+        stateManager = new CookieStateManager();
+        stateManager.setId("test");
+        stateManager.setCookiePrefix("__Host-shibsp_state");
+
+        final ObjectMapper mapper = new ObjectMapper();
+        mapper.registerModule(new JavaTimeModule());
+        stateManager.setObjectMapper(mapper);
+        
+        stateManager.setHttpServletRequestSupplier(new NonnullSupplier<HttpServletRequest>() {
+            @Nonnull public HttpServletRequest get() {
+                assert request2 != null;
+                return request2;
+            }
+        });
+        
+        stateManager.setCookieManager(cookieManager);
+        stateManager.initialize();
+    }
+    
+    /**
+     * Tear down state manager.
+     */
+    @AfterClass
+    public void tearDown() {
+        stateManager.destroy();
+        cookieManager.destroy();
+    }
+    
     /** Pre-test work. */
     @BeforeMethod
     public void beforeMethod() {
@@ -143,10 +236,7 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
     public void testNoInput() throws IOException {
         setApplicationRequest(APPLICATION_ID, null);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        assertOutputMessageEvent(result, AuthnEventIds.NO_POTENTIAL_FLOW);
+        validateError(AuthnEventIds.NO_POTENTIAL_FLOW, null);
     }
     
     /**
@@ -156,15 +246,12 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testErrorStatus() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.RESPONDER);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.RESPONDER, null);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
+        validateError(EventIds.INVALID_MESSAGE, null);
     }
     
     /**
@@ -174,13 +261,10 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testUnverified() throws IOException {
-        final DDF input = buildRemotedPOSTResponse(buildSAMLResponse(ISSUER + "/bad", StatusCode.SUCCESS));
+        final DDF input = buildRemotedPOSTResponse(buildSAMLResponse(ISSUER + "/bad", StatusCode.SUCCESS, null), null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        assertOutputMessageEvent(result, IdPEventIds.INVALID_PROFILE_CONFIG);
+        validateError(IdPEventIds.INVALID_PROFILE_CONFIG, null);
     }
     
     /**
@@ -190,16 +274,13 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testExpired() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         response.setIssueInstant(Instant.EPOCH);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
+        validateError(EventIds.INVALID_MESSAGE, null);
     }
 
     /**
@@ -209,18 +290,15 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testBadDestination() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         response.setDestination(RESPONSE_URL + "/bad");
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
+        validateError(EventIds.INVALID_MESSAGE, null);
     }
-
+    
     /**
      * Test flow with expired assertion issue instant.
      * 
@@ -228,17 +306,13 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testAssertionExpired() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         response.getAssertions().get(0).setIssueInstant(Instant.now().minusSeconds(1800));
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, "Assertion IssueInstant was expired");
+        validateError(EventIds.INVALID_MESSAGE, "Assertion IssueInstant was expired");
     }
 
     /**
@@ -248,20 +322,17 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testSubjectLocality() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final SubjectLocality locality = response.getAssertions().get(0).getAuthnStatements().get(0).getSubjectLocality();
         assert locality != null;
         locality.setAddress("127.0.0.1");
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("SubjectLocality/@Address for assertion '%s' did not match supplied valid addresses: [/192.168.1.1]",
-                response.getAssertions().get(0).getID()));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectLocality/@Address for assertion '%s' did not match supplied valid addresses: [/192.168.1.1]",
+                        response.getAssertions().get(0).getID()));
     }
 
     /**
@@ -271,45 +342,39 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testBadConfirmationAddress() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Subject subject = response.getAssertions().get(0).getSubject();
         assert subject != null;
         final SubjectConfirmationData data = subject.getSubjectConfirmations().get(0).getSubjectConfirmationData();
         assert data != null;
         data.setAddress("127.0.0.1");
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("SubjectConfirmationData/@Address for assertion '%s' did not match supplied valid addresses: [/192.168.1.1]",
-                response.getAssertions().get(0).getID()));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@Address for assertion '%s' did not match supplied valid addresses: [/192.168.1.1]",
+                        response.getAssertions().get(0).getID()));
     }
 
     /**
-     * Test flow with bad confirmation address.
+     * Test flow with bad confirmation method.
      * 
      * @throws IOException 
      */
     @Test
     public void testBadConfirmationMethod() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Subject subject = response.getAssertions().get(0).getSubject();
         assert subject != null;
         subject.getSubjectConfirmations().get(0).setMethod(SubjectConfirmation.METHOD_SENDER_VOUCHES);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("No subject confirmation methods were met for assertion with ID '%s'",
-                response.getAssertions().get(0).getID()));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("No subject confirmation methods were met for assertion with ID '%s'",
+                        response.getAssertions().get(0).getID()));
     }
 
     /**
@@ -319,22 +384,19 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testNoNotOnOrAfter() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Subject subject = response.getAssertions().get(0).getSubject();
         assert subject != null;
         final SubjectConfirmationData data = subject.getSubjectConfirmations().get(0).getSubjectConfirmationData();
         assert data != null;
         data.setNotOnOrAfter(null);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("SubjectConfirmationData/@NotOnOrAfter was missing and was required",
-                response.getAssertions().get(0).getID(), RESPONSE_URL));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@NotOnOrAfter was missing and was required",
+                        response.getAssertions().get(0).getID(), RESPONSE_URL));
     }
     
     /**
@@ -344,22 +406,19 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testNoRecipient() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Subject subject = response.getAssertions().get(0).getSubject();
         assert subject != null;
         final SubjectConfirmationData data = subject.getSubjectConfirmations().get(0).getSubjectConfirmationData();
         assert data != null;
         data.setRecipient(null);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("SubjectConfirmationData/@Recipient was missing and was required",
-                response.getAssertions().get(0).getID(), RESPONSE_URL));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@Recipient was missing and was required",
+                        response.getAssertions().get(0).getID(), RESPONSE_URL));
     }
     
     /**
@@ -369,23 +428,37 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testBadRecipient() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Subject subject = response.getAssertions().get(0).getSubject();
         assert subject != null;
         final SubjectConfirmationData data = subject.getSubjectConfirmations().get(0).getSubjectConfirmationData();
         assert data != null;
         data.setRecipient(RESPONSE_URL + "/bad");
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("SubjectConfirmationData/@Recipient for assertion '%s' did not match any valid recipients: [%s]",
-                response.getAssertions().get(0).getID(), RESPONSE_URL));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@Recipient for assertion '%s' did not match any valid recipients: [%s]",
+                        response.getAssertions().get(0).getID(), RESPONSE_URL));
     }
+    
+    /**
+     * Test flow with bad confirmation InResponseTo (no state supplied).
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testBadInResponseTo() throws IOException {
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "bad");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
+        setApplicationRequest(APPLICATION_ID, input);
+
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@InResponseTo for assertion '%s' did not match the valid value: null",
+                        response.getAssertions().get(0).getID()));
+    }    
 
     /**
      * Test flow with bad issuer value.
@@ -394,17 +467,13 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testAssertionBadIssuer() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         response.getAssertions().get(0).setIssuer(SAML2ActionTestingSupport.buildIssuer(ISSUER + "bad"));
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output,
+        validateError(EventIds.INVALID_MESSAGE,
                 String.format("Issuer of Assertion '%s' did not match any valid issuers",
                         response.getAssertions().get(0).getID()));
     }
@@ -416,19 +485,15 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testAssertionBadIssuerFormat() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Issuer issuer = SAML2ActionTestingSupport.buildIssuer(ISSUER);
         issuer.setFormat(NameIDType.EMAIL);
         response.getAssertions().get(0).setIssuer(issuer);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, "Issuer had invalid Format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
+        validateError(EventIds.INVALID_MESSAGE, "Issuer had invalid Format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
     }
 
     /**
@@ -438,15 +503,11 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testUnsigned() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, "Assertion was required to be signed, but was not");
+        validateError(EventIds.INVALID_MESSAGE, "Assertion was required to be signed, but was not");
     }
 
     /**
@@ -456,17 +517,13 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testNoConditions() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         response.getAssertions().get(0).setConditions(null);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, "At least 1 Condition was indicated as required");
+        validateError(EventIds.INVALID_MESSAGE, "At least 1 Condition was indicated as required");
     }
 
     /**
@@ -476,20 +533,16 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testExpiredCondition() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Conditions conditions = response.getAssertions().get(0).getConditions();
         assert conditions != null;
         conditions.setNotOnOrAfter(Instant.now().minusSeconds(300));
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("Assertion '%s' with NotOnOrAfter condition",
-                response.getAssertions().get(0).getID()));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("Assertion '%s' with NotOnOrAfter condition", response.getAssertions().get(0).getID()));
     }
 
     /**
@@ -499,22 +552,143 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testBadAudience() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final Conditions conditions = response.getAssertions().get(0).getConditions();
         assert conditions != null;
         conditions.getAudienceRestrictions().get(0).getAudiences().get(0).setURI(AUDIENCE + "/bad");
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
-        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
-        assertFlowExecutionResult(result, FLOW_ID);
-        assertFlowExecutionOutcome(result.getOutcome());
-        final DDF output = assertOutputMessageEvent(result, EventIds.INVALID_MESSAGE);
-        validateAssertionError(output, String.format("None of the audiences within Assertion '%s' matched the list of valid audiances",
-                response.getAssertions().get(0).getID()));
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("None of the audiences within Assertion '%s' matched the list of valid audiances",
+                        response.getAssertions().get(0).getID()));
+    }
+
+    /**
+     * Test absent SubjectConfirmation.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testMissingConfirmation() throws IOException {
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
+        Constraint.isNotNull(response.getAssertions().get(0).getSubject(), "No Subject").getSubjectConfirmations().clear();
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
+        setApplicationRequest(APPLICATION_ID, input);
+
+        validateError(EventIds.INVALID_MESSAGE, "No valid assertions suitable for authentication were found");
+    }
+
+    /**
+     * Test no statements.
+     * 
+     * @throws IOException 
+     */
+    @Test
+    public void testMissingStatement() throws IOException {
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
+        response.getAssertions().get(0).getAuthnStatements().clear();
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
+        setApplicationRequest(APPLICATION_ID, input);
+
+        validateError(EventIds.INVALID_MESSAGE, "No valid assertions suitable for authentication were found");
     }
+    
+    /**
+     * Test failure due to state recovery address.
+     * 
+     * @throws IOException
+     * @throws ResolverException 
+     */
+    @Test
+    public void testFailedStateAddress() throws IOException, ResolverException {
+        
+        final StateData data = buildStateData("foo");
+        data.setClientAddress("192.168.1.2");
+        final String stateToken = getStateToken(data);
+        
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "foo");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, stateToken, response2.getCookies()[0]);
+
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        validateError(EventIds.INVALID_MESSAGE,
+                String.format("SubjectConfirmationData/@InResponseTo for assertion '%s' did not match the valid value: null",
+                        response.getAssertions().get(0).getID()));
+    }
+    
+    /**
+     * Test failure due to state recovery authority.
+     * 
+     * @throws IOException
+     * @throws ResolverException 
+     */
+    @Test
+    public void testFailedStateAuthority() throws IOException, ResolverException {
+        
+        final StateData data = buildStateData("foo");
+        data.setAuthenticationAuthority(ISSUER + "/bad");
+        final String stateToken = getStateToken(data);
+        
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "foo");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, stateToken, response2.getCookies()[0]);
+
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        validateError(EventIds.INVALID_MESSAGE, null);
+    }
+    
+    /**
+     * Test failure due to state response URL.
+     * 
+     * @throws IOException
+     * @throws ResolverException 
+     */
+    @Test
+    public void testFailedStateLocation() throws IOException, ResolverException {
+        
+        final StateData data = buildStateData("foo");
+        data.setResponseLocation(RESPONSE_URL + "/bad");
+        final String stateToken = getStateToken(data);
+        
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "foo");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, stateToken, response2.getCookies()[0]);
+
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        validateError(EventIds.INVALID_MESSAGE, null);
+    }
+    
+
+    /**
+     * Test failure due to state recovery address.
+     * 
+     * @throws IOException
+     * @throws ResolverException 
+     */
+    @Test
+    public void testFailedStateAuthnContext() throws IOException, ResolverException {
+        
+        final StateData data = buildStateData("foo");
+        data.setAcrs(CollectionSupport.singletonList(AuthnContext.X509_AUTHN_CTX));
+        
+        final String stateToken = getStateToken(data);
+        
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "foo");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, stateToken, response2.getCookies()[0]);
 
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        validateError(EventIds.INVALID_MESSAGE, "No suitable AuthnContextClassRef found");
+    }
+    
     /**
      * Test successful flow.
      * 
@@ -522,9 +696,9 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testSuccess() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
         final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
@@ -537,6 +711,35 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
                 RESOURCE_URL, response.getAssertions().get(0).getAuthnStatements().get(0).getSessionIndex());
     }
 
+    /**
+     * Test successful flow with InResponseTo available.
+     * 
+     * @throws IOException
+     * @throws ResolverException 
+     */
+    @Test
+    public void testSuccessWithState() throws IOException, ResolverException {
+        
+        final StateData data = buildStateData("foo");
+        data.setAcrs(CollectionSupport.singletonList(AuthnContext.PPT_AUTHN_CTX));
+        final String stateToken = getStateToken(data);
+        
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, "foo");
+        sign(response);
+        final DDF input = buildRemotedPOSTResponse(response, stateToken, response2.getCookies()[0]);
+
+        setApplicationRequest(APPLICATION_ID, input);
+        
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        final DDF output = assertOutputMessageSuccess(result);
+        assert output != null;
+        System.out.println("testSuccess output: " + output.toString());
+        validateOutputMessage(result, CollectionSupport.singleton("mail"),
+                RESOURCE_URL, response.getAssertions().get(0).getAuthnStatements().get(0).getSessionIndex());
+    }
+    
     /**
      * Test successful flow with attributes.
      * 
@@ -544,7 +747,7 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      */
     @Test
     public void testSuccessAttributes() throws IOException {
-        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS);
+        final Response response = buildSAMLResponse(ISSUER, StatusCode.SUCCESS, null);
         final AttributeStatement statement = SAML2ActionTestingSupport.buildAttributeStatement();
         statement.getAttributes().add(
                 SAML2ActionTestingSupport.buildAttribute("urn:oid:2.16.840.1.113730.3.1.241", Attribute.URI_REFERENCE,
@@ -554,7 +757,7 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
                         CollectionSupport.listOf("staff at example.org", "employee at example.org")));
         response.getAssertions().get(0).getAttributeStatements().add(statement);
         sign(response);
-        final DDF input = buildRemotedPOSTResponse(response);
+        final DDF input = buildRemotedPOSTResponse(response, null, null);
         setApplicationRequest(APPLICATION_ID, input);
 
         final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
@@ -639,6 +842,23 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
         return output;
     }
     
+    /**
+     * Run the flow and verify an event is signalled, optionally
+     * ensuring an assertion validation message matches an expected message.
+     * 
+     * @param event event to check for
+     * @param assertionError optional message to scan for
+     */
+    private void validateError(@Nonnull final String event, @Nullable final String assertionError) {
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertFlowExecutionResult(result, FLOW_ID);
+        assertFlowExecutionOutcome(result.getOutcome());
+        final DDF output = assertOutputMessageEvent(result, event);
+        if (assertionError != null) {
+            validateAssertionError(output, assertionError);
+        }
+    }
+    
     /**
      * Tests the output contains token validation error starting with the designated string.
      * 
@@ -658,19 +878,20 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
         Assert.assertTrue(msg != null && msg.startsWith(messagePrefix));
     }
     
-    
     /**
      * Builds a SAML response with some tailored data.
      * 
      * @param issuer issuer value
      * @param code status code string
+     * @param requestID ID to place in InResponseTp
      * 
      * @return input object suitable for token consumer flow
      */
-    @Nonnull private Response buildSAMLResponse(@Nonnull final String issuer, @Nonnull final String code) {
+    @Nonnull private Response buildSAMLResponse(@Nonnull final String issuer, @Nonnull final String code, @Nullable final String requestID) {
         
         final Response response = SAML2ActionTestingSupport.buildResponse();
         response.setID(idGenerator.generateIdentifier());
+        response.setInResponseTo(requestID);
         response.setIssueInstant(Instant.now());
         response.setDestination(RESPONSE_URL);
         response.setIssuer(SAML2ActionTestingSupport.buildIssuer(issuer));
@@ -687,9 +908,12 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
             assert nameID != null;
             nameID.setFormat(NameIDType.EMAIL);
             
-            subject.getSubjectConfirmations().add(
-                    SAML2ActionTestingSupport.buildSubjectConfirmation(
-                            SubjectConfirmation.METHOD_BEARER, RESPONSE_URL, "192.168.1.1"));
+            final SubjectConfirmation sc = SAML2ActionTestingSupport.buildSubjectConfirmation(
+                    SubjectConfirmation.METHOD_BEARER, RESPONSE_URL, "192.168.1.1");
+            final SubjectConfirmationData scdata = sc.getSubjectConfirmationData();
+            assert scdata != null;
+            scdata.setInResponseTo(requestID);
+            subject.getSubjectConfirmations().add(sc);
             
             assertion.setSubject(subject);
             
@@ -712,18 +936,22 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
      * Encodes a SAML response into a form POST embedded in a remoted message.
      * 
      * @param response SAML response to encode
+     * @param relayState relay state if any
+     * @param stateCookie the state cookie to attack to the input message if any
      * 
      * @return input object suitable for token consumer flow
      * 
      * @throws IOException on error 
      */
-    @Nonnull private DDF buildRemotedPOSTResponse(@Nonnull final Response response) throws IOException {
+    @Nonnull private DDF buildRemotedPOSTResponse(@Nonnull final Response response, @Nullable final String relayState,
+            @Nullable Cookie stateCookie)
+            throws IOException {
                 
         try (final ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
             XMLObjectSupport.marshallToOutputStream(response, sink);
             final String base64 = Base64Support.encode(sink.toByteArray(), true);
             final DDF obj = new DDF(null).structure();
-            obj.addmember(ConsumerConstants.BASE_URL).unsafe_string(RESOURCE_URL.getBytes(StandardCharsets.UTF_8));
+            obj.addmember(ConsumerConstants.BASE_URL).unsafe_string(RESOURCE_URL.getBytes(StandardCharsets.UTF_8));            
             final DDF http = obj.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
             
             http.addmember(RemotedHttpServletRequest.METHOD).string("POST");
@@ -731,8 +959,21 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
             http.addmember(RemotedHttpServletRequest.CONTENT_TYPE).string("application/x-www-form-urlencoded");
             http.addmember(RemotedHttpServletRequest.REQUEST_URL).unsafe_string(RESPONSE_URL.getBytes(StandardCharsets.UTF_8));
             
-            http.addmember(RemotedHttpServletRequest.BODY).unsafe_string(Constraint.isNotNull(URISupport.buildQuery(
-                    CollectionSupport.listOf(new Pair<>("SAMLResponse", base64))), "Query string is null").getBytes(StandardCharsets.UTF_8));
+            if (stateCookie != null) {
+                http.addmember("headers").structure().addmember("Cookie")
+                    .unsafe_string(new String(stateCookie.getName() + '=' + stateCookie.getValue()).getBytes(StandardCharsets.UTF_8));
+            }
+            
+            final List<Pair<String,String>> params;
+            if (relayState != null) {
+                params = CollectionSupport.listOf(new Pair<>("SAMLResponse", base64),
+                        new Pair<>("RelayState", relayState));
+            } else {
+                params = CollectionSupport.singletonList(new Pair<>("SAMLResponse", base64));
+            }
+            
+            http.addmember(RemotedHttpServletRequest.BODY).unsafe_string(
+                    Constraint.isNotNull(URISupport.buildQuery(params), "Query string is null").getBytes(StandardCharsets.UTF_8));
             
             return obj;
         } catch (final MarshallingException | EncodingException e) {
@@ -761,4 +1002,43 @@ public class SAML2TokenConsumerFlowTest extends AbstractSPFlowTest {
         }
     }
 
+    /**
+     * Create state object.
+     * 
+     * @param requestID message ID to include
+     * 
+     * @return state object
+     */
+    @Nonnull private SAMLStateData buildStateData(@Nonnull final String requestID) {
+        final SAMLStateData data = new SAMLStateData();
+        data.setRequestID(requestID);
+        data.setAuthenticationAuthority(ISSUER);
+        data.setResponseLocation(RESPONSE_URL);
+        data.setRawResource(RESOURCE_URL.getBytes(StandardCharsets.UTF_8));
+        return data;
+    }
+
+    /**
+     * Generate a state token for the supplied data.
+     * 
+     * @param data state to preserve
+     * 
+     * @return the token to use as RelayState
+     * 
+     * @throws ResolverException
+     * @throws IOException
+     */
+    @Nonnull private String getStateToken(@Nonnull final StateData data) throws ResolverException, IOException {
+        try (final ServiceableComponent<AgentResolver> resolver = agentResolver.getServiceableComponent()) {
+            final Agent agent = resolver.getComponent().resolveSingle(
+                    new CriteriaSet(new AgentCriterion(AGENT_ID)));
+            assert agent != null;
+            final Application app = agent.getApplication(APPLICATION_ID);
+            assert app != null;
+            request2 = new MockHttpServletRequest();
+            response2 = new MockHttpServletResponse();
+            return stateManager.preserveToStateToken(agent, app, data);
+        }
+    }
+    
 }
\ No newline at end of file
diff --git a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/TestSPSAMLEnvironmentApplicationContextInitializer.java b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/TestSPSAMLEnvironmentApplicationContextInitializer.java
index 5c719ef..2c54daf 100644
--- a/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/TestSPSAMLEnvironmentApplicationContextInitializer.java
+++ b/sp-saml-conf-impl/src/test/java/net/shibboleth/sp/saml/flows/saml2/TestSPSAMLEnvironmentApplicationContextInitializer.java
@@ -32,6 +32,7 @@ public class TestSPSAMLEnvironmentApplicationContextInitializer extends TestSPEn
         super.addProperties(mock);
         mock.setProperty("sp.service.agents.resources", "test.sp.saml.AgentResolverResources");
         mock.setProperty("sp.agent.authn.method", "basic");
+        mock.setProperty("sp.stateToken.sealed", "false");
         mock.setProperty("idp.additionalProperties",
                 "/conf/ldap.properties, /conf/saml-nameid.properties, /conf/services.properties, /conf/admin/admin.properties, /conf/authn/authn.properties, /conf/c14n/subject-c14n.properties, /credentials/secrets.properties, /conf/sp/sp.properties, /conf/sp/saml.properties");
     }
diff --git a/sp-saml-impl/pom.xml b/sp-saml-impl/pom.xml
index 5af36b1..57d9d4c 100644
--- a/sp-saml-impl/pom.xml
+++ b/sp-saml-impl/pom.xml
@@ -44,6 +44,11 @@
             <artifactId>idp-profile-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${idp.groupId}</groupId>
+            <artifactId>idp-saml-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
 
         <dependency>
             <groupId>${shib-profile.groupId}</groupId>
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/CheckDestinationAndIssuerHandler.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/CheckDestinationAndIssuerHandler.java
new file mode 100644
index 0000000..5594955
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/CheckDestinationAndIssuerHandler.java
@@ -0,0 +1,93 @@
+/*
+ * 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 net.shibboleth.sp.saml.saml2.messaging.impl;
+
+import java.util.Objects;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.InOutOperationContext;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.messaging.context.navigate.RecursiveTypedParentContextLookup;
+import org.opensaml.messaging.handler.AbstractHttpServletRequestMessageHandler;
+import org.opensaml.messaging.handler.MessageHandlerException;
+import org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext;
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+import net.shibboleth.sp.state.StateData;
+
+/**
+ * Message handler that cross checks the message destination and issuer against previously
+ * tracked {@link StateData} if any.
+ */
+public class CheckDestinationAndIssuerHandler extends AbstractHttpServletRequestMessageHandler {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(CheckDestinationAndIssuerHandler.class);
+    
+    /** Lookup strategy for {@link StateDataContext}. */
+    @Nonnull private Function<MessageContext,StateDataContext> stateDataContextLookupStrategy;
+    
+    /** Constructor. */
+    @SuppressWarnings("null")
+    public CheckDestinationAndIssuerHandler() {
+        stateDataContextLookupStrategy = new ChildContextLookup<>(StateDataContext.class).compose(
+                new RecursiveTypedParentContextLookup<>(InOutOperationContext.class));
+    }
+    
+    /**
+     * Sets lookup strategy for {@link StateDataContext}.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setStateDataContextLookupStrategy(@Nonnull final Function<MessageContext,StateDataContext> strategy) {
+        stateDataContextLookupStrategy = Constraint.isNotNull(strategy,
+                "StateDataContext lookup strategy cannot be null");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    public void doInvoke(@Nonnull final MessageContext messageContext) throws MessageHandlerException {
+        final StateDataContext context = stateDataContextLookupStrategy.apply(messageContext);
+        if (context != null && context.getStateData() instanceof SAMLStateData samlState) {
+            
+            // Check issuer.
+            final SAMLPeerEntityContext peer = messageContext.getSubcontext(SAMLPeerEntityContext.class);
+            if (peer != null && samlState.getAuthenticationAuthority() != null
+                    && !Objects.equals(peer.getEntityId(), samlState.getAuthenticationAuthority())) {
+                log.warn("{} Issuer mismatch, message issued by {}, expected issuer was {} ", getLogPrefix(),
+                        peer.getEntityId(), samlState.getAuthenticationAuthority());
+                throw new MessageHandlerException("Message issuer did not match expected request recipient.");
+            }
+            
+            // Check destination.
+            final HttpServletRequest request = getHttpServletRequest();
+            if (request != null && samlState.getResponseLocation() != null
+                    && !Objects.equals(samlState.getResponseLocation(), request.getRequestURL().toString())) {
+                log.warn("{} Destination mismatch, message expected at {}, delivered to {}", getLogPrefix(),
+                        samlState.getResponseLocation(), request.getRequestURL());
+                throw new MessageHandlerException("Message destination did not match expected location.");
+            }
+        }
+    }
+
+}
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/package-info.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/package-info.java
new file mode 100644
index 0000000..d2b5a09
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/messaging/impl/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.
+ */
+
+/**
+ * Message handlers for SAML 2 SP.
+ */
+package net.shibboleth.sp.saml.saml2.messaging.impl;
\ No newline at end of file
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/impl/BrowserSSOProfileConfiguration.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/impl/BrowserSSOProfileConfiguration.java
index 061b0b6..2383005 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/impl/BrowserSSOProfileConfiguration.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/config/impl/BrowserSSOProfileConfiguration.java
@@ -41,13 +41,15 @@ import org.opensaml.saml.common.xml.SAMLConstants;
 import org.opensaml.saml.saml2.core.AuthnContextClassRef;
 import org.opensaml.saml.saml2.core.AuthnContextComparisonTypeEnumeration;
 import org.opensaml.saml.saml2.core.AuthnRequest;
+import org.opensaml.saml.saml2.core.RequestedAuthnContext;
 import org.opensaml.saml.saml2.core.SubjectConfirmationData;
 import org.opensaml.saml.saml2.core.SubjectLocality;
 import org.opensaml.saml.saml2.metadata.RequestedAttribute;
 
 /** Configuration support for SP SAML 2.0 Browser SSO. */
 public class BrowserSSOProfileConfiguration extends AbstractSAML2AssertionConsumerProfileConfiguration
-        implements SAMLArtifactConsumerProfileConfiguration, net.shibboleth.sp.saml.saml2.profile.config.BrowserSSOProfileConfiguration {
+        implements SAMLArtifactConsumerProfileConfiguration,
+            net.shibboleth.sp.saml.saml2.profile.config.BrowserSSOProfileConfiguration {
 
     /** Whether attributes should be resolved in the course of the profile. */
     @Nonnull private Predicate<ProfileRequestContext> resolveAttributesPredicate;
@@ -88,6 +90,9 @@ public class BrowserSSOProfileConfiguration extends AbstractSAML2AssertionConsum
     /** Lookup function to supply default authentication methods. */
     @Nonnull private Function<ProfileRequestContext,Collection<String>> authnContextClassRefLookupStrategy;
     
+    /** Whether to validate incoming ACRs. */
+    @Nonnull private Predicate<ProfileRequestContext> validateAuthnContextClassRefsPredicate;
+    
     /** Lookup function to supply NameID format. */
     @Nonnull private Function<ProfileRequestContext,String> nameIDFormatLookupStrategy;
 
@@ -139,6 +144,7 @@ public class BrowserSSOProfileConfiguration extends AbstractSAML2AssertionConsum
         proxyCountLookupStrategy = FunctionSupport.constant(null);
         authnContextComparisonLookupStrategy = FunctionSupport.constant(null);
         authnContextClassRefLookupStrategy = FunctionSupport.constant(null);
+        validateAuthnContextClassRefsPredicate = PredicateSupport.alwaysTrue();
         nameIDFormatLookupStrategy = FunctionSupport.constant(null);
         nameQualifierLookupStrategy = FunctionSupport.constant(null);
         attributeIndexLookupStrategy = FunctionSupport.constant(null);
@@ -513,6 +519,31 @@ public class BrowserSSOProfileConfiguration extends AbstractSAML2AssertionConsum
             @Nonnull final Function<ProfileRequestContext,Collection<String>> strategy) {
         authnContextClassRefLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
     }
+    /** {@inheritDoc} */
+    public boolean isValidateAuthnContextClassRefs(@Nullable final ProfileRequestContext profileRequestContext) {
+        return validateAuthnContextClassRefsPredicate.test(profileRequestContext);
+    }
+    
+    /**
+     * Set whether to validate the incoming assertions' {@link AuthnContextClassRef} against any
+     * {@link RequestedAuthnContext} included in the original request.
+     * 
+     * @param flag flag to set
+     */
+    public void setValidateAuthnContextClassRefs(final boolean flag) {
+        validateAuthnContextClassRefsPredicate = PredicateSupport.constant(flag);
+    }
+    
+    /**
+     * Set a condition for whether to validate the incoming assertions' {@link AuthnContextClassRef} against any
+     * {@link RequestedAuthnContext} included in the original request.
+     * 
+     * @param condition condition to set
+     */
+    public void setValidateAuthnContextClassRefsPredicate(@Nonnull final Predicate<ProfileRequestContext> condition) {
+        validateAuthnContextClassRefsPredicate = Constraint.isNotNull(condition,
+                "Validate AuthnContextClassRefs predicate cannot be null");
+    }
 
     /** {@inheritDoc} */
     @Nullable public String getNameIDFormat(@Nullable final ProfileRequestContext profileRequestContext) {
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddAuthnRequest.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddAuthnRequest.java
index 055c45f..ee666ba 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddAuthnRequest.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddAuthnRequest.java
@@ -33,12 +33,16 @@ import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.sp.context.StateDataContext;
 import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
 import net.shibboleth.sp.profile.AbstractApplicationAction;
 import net.shibboleth.sp.profile.InitiatorConstants;
 import net.shibboleth.sp.profile.SPConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
 import net.shibboleth.sp.saml.saml2.profile.SAML2InitiatorConstants;
 import net.shibboleth.sp.saml.saml2.profile.config.BrowserSSOProfileConfiguration;
+import net.shibboleth.sp.state.StateData;
 
 import org.opensaml.core.xml.XMLObjectBuilderFactory;
 import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
@@ -46,12 +50,12 @@ import org.opensaml.core.xml.io.MarshallingException;
 import org.opensaml.core.xml.io.UnmarshallingException;
 import org.opensaml.core.xml.util.XMLObjectSupport;
 import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.saml.common.SAMLObjectBuilder;
 import org.opensaml.saml.common.SAMLVersion;
-import org.opensaml.saml.common.binding.SAMLBindingSupport;
 import org.opensaml.saml.ext.reqattr.RequestedAttributes;
 import org.opensaml.saml.saml2.core.AuthnContextClassRef;
 import org.opensaml.saml.saml2.core.AuthnContextComparisonTypeEnumeration;
@@ -65,6 +69,7 @@ import org.opensaml.saml.saml2.core.Scoping;
 import org.opensaml.saml.saml2.core.Subject;
 import org.opensaml.saml.saml2.metadata.RequestedAttribute;
 import org.slf4j.Logger;
+
 import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
@@ -78,6 +83,9 @@ import net.shibboleth.shared.primitive.LoggerFactory;
  * marrying together inputs from the agent against the profile configuration, including enforcing limits
  * on what the agent can override/supply.</p>
  * 
+ * <p>This action is also responsible for creating a {@link StateDataContext} and populating a
+ * {@link SAMLStateData} object with any relevant state necessary to preserve.</p>
+ * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  * @event {@link EventIds#INVALID_MESSAGE}
  * @event {@link EventIds#INVALID_MSG_CTX}
@@ -85,6 +93,7 @@ import net.shibboleth.shared.primitive.LoggerFactory;
  * @event {@link IdPEventIds#INVALID_PROFILE_CONFIG}
  * 
  * @post ProfileRequestContext.getOutboundMessageContext().getMessage() != null
+ * @post ProfileRequestContext.ensureSubcontext(StateDataContext.class).getStateData() != null
  */
 public class AddAuthnRequest extends AbstractApplicationAction {
 
@@ -93,10 +102,13 @@ public class AddAuthnRequest extends AbstractApplicationAction {
     
     /** Overwrite an existing message? */
     private boolean overwriteExisting;
-
+    
     /** Strategy used to locate the {@link IdentifierGenerationStrategy} to use. */
     @Nonnull private Function<ProfileRequestContext,IdentifierGenerationStrategy> idGeneratorLookupStrategy;
     
+    /** Strategy used to create the {@link StateDataContext} to populate. */
+    @Nonnull private Function<ProfileRequestContext,StateDataContext> stateDataContextCreationStrategy;
+    
     /** Strategy used to obtain the request issuer value. */
     @Nullable private Function<ProfileRequestContext,String> issuerLookupStrategy;
 
@@ -115,6 +127,9 @@ public class AddAuthnRequest extends AbstractApplicationAction {
     /** Input message from agent. */
     @NonnullBeforeExec private DDF input;
     
+    /** Cached state data object to populate. */
+    @NonnullBeforeExec private SAMLStateData stateData;
+    
     /** EntityID to populate into Issuer element. */
     @Nullable private String issuerId;
     
@@ -123,11 +138,13 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         // Default strategy is a 16-byte secure random source.
         idGeneratorLookupStrategy = new IdentifierGenerationStrategyLookupFunction();
 
+        stateDataContextCreationStrategy = new ChildContextLookup<>(StateDataContext.class, true);
+        
         issuerLookupStrategy = new IssuerLookupFunction();
         
         inboundBindingMap = CollectionSupport.emptyMap();
     }
-        
+
     /**
      * Set whether to overwrite an existing message.
      * 
@@ -149,6 +166,18 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         idGeneratorLookupStrategy =
                 Constraint.isNotNull(strategy, "IdentifierGenerationStrategy lookup strategy cannot be null");
     }
+    
+    /**
+     * Sets the strategy used to create the {@link StateDataContext}.
+     * 
+     * @param strategy creation strategy
+     */
+    public void setStateDataContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,StateDataContext> strategy) {
+        checkSetterPreconditions();
+        stateDataContextCreationStrategy =
+                Constraint.isNotNull(strategy, "StateDataContext creation strategy cannot be null");
+    }
 
     /**
      * Set the strategy used to locate the issuer value to use.
@@ -232,7 +261,30 @@ public class AddAuthnRequest extends AbstractApplicationAction {
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
             return false;
         }
+
+        final StateDataContext stateDataContext = stateDataContextCreationStrategy.apply(profileRequestContext);
+        if (stateDataContext == null) {
+            log.error("{} Error creating StateDataContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        // Get target URL from either existing StateData or input message.
+        byte[] target = input.getmember(SPConstants.TARGET).unsafe_string();
+        if (target == null) {
+            final StateData oldStateData = stateDataContext.getStateData();
+            if (oldStateData != null) {
+                target = oldStateData.getRawResource();
+            }
+        }
         
+        // Establish fresh StateData of the correct type.
+        stateData = new SAMLStateData();
+        stateDataContext.setStateData(stateData);
+        assert rpCtx != null;
+        stateData.setAuthenticationAuthority(rpCtx.getRelyingPartyId());
+        stateData.setRawResource(target);
+                
         if (issuerLookupStrategy != null) {
             issuerId = issuerLookupStrategy.apply(profileRequestContext);
         }
@@ -254,16 +306,19 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         final SAMLObjectBuilder<NameIDPolicy> nipBuilder =
                 (SAMLObjectBuilder<NameIDPolicy>) bf.<NameIDPolicy>ensureBuilder(
                         NameIDPolicy.DEFAULT_ELEMENT_NAME);
-
+        
         final AuthnRequest object = requestBuilder.buildObject();
         object.setID(idGenerator.generateIdentifier());
         object.setIssueInstant(Instant.now());
         object.setVersion(SAMLVersion.VERSION_20);
         
+        stateData.setRequestID(object.getID());
+        
         if (!setResponseEndpoint(profileRequestContext, object)) {
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
             return;
         }
+        stateData.setResponseLocation(object.getAssertionConsumerServiceURL());
 
         log.debug("{} Response endpoint ({}), binding ({})", getLogPrefix(),
                 object.getAssertionConsumerServiceURL(), object.getProtocolBinding());
@@ -294,6 +349,7 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         if (passive != null && passive == 1) {
             log.debug("{} Setting IsPassive to true", getLogPrefix());
             object.setIsPassive(true);
+            stateData.setPassive(true);
         }
 
         object.setNameIDPolicy(buildNameIDPolicy(profileRequestContext, nipBuilder));
@@ -304,10 +360,12 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         
         profileRequestContext.ensureOutboundMessageContext().setMessage(object);
         
-        // Check for RelayState.
-        final String relayState = input.getmember(SPConstants.STATE).string();
-        if (relayState != null) {
-            SAMLBindingSupport.setRelayState(profileRequestContext.ensureOutboundMessageContext(), relayState);
+        if (profileConfiguration.isCheckAddress(profileRequestContext)) {
+            // We could create all the machinery to wrap this in a servlet interface, but...
+            stateData.setClientAddress(
+                    input.getmember(RemotedHttpServletRequest.STRUCTURE_NAME)
+                        .getmember(RemotedHttpServletRequest.REMOTE_ADDR)
+                        .string());
         }
         
         log.info("{} Generated AuthnRequest with ID {} from {}", getLogPrefix(), object.getID(), issuerId);
@@ -480,6 +538,9 @@ public class AddAuthnRequest extends AbstractApplicationAction {
 
         log.debug("{} Setting requested AuthnContextClassRef(s) {}", getLogPrefix(), classrefs);
 
+        // If we have to validate later, track what's being requested in the state record.
+        final boolean enforcing = profileConfiguration.isValidateAuthnContextClassRefs(profileRequestContext);
+        
         final XMLObjectBuilderFactory bf = XMLObjectProviderRegistrySupport.getBuilderFactory();
 
         final SAMLObjectBuilder<RequestedAuthnContext> builder =
@@ -496,6 +557,9 @@ public class AddAuthnRequest extends AbstractApplicationAction {
             final AuthnContextClassRef obj = acBuilder.buildObject();
             obj.setURI(ref);
             rac.getAuthnContextClassRefs().add(obj);
+            if (enforcing) {
+                stateData.getAcrs().add(ref);
+            }
         });
         
         String opstring = input.getmember(SAML2InitiatorConstants.AUTHN_CONTEXT_COMPARISON).string();
@@ -519,6 +583,9 @@ public class AddAuthnRequest extends AbstractApplicationAction {
         if (operator != null) {
             log.debug("{} Setting RequestedAuthnContext operator to {}", getLogPrefix(), operator);
             rac.setComparison(operator);
+            if (enforcing) {
+                stateData.setAuthnContextOperator(operator.toString());
+            }
         }
         
         return rac;
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutRequest.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutRequest.java
index 8fab808..171365f 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutRequest.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/AddLogoutRequest.java
@@ -27,11 +27,14 @@ import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.sp.context.StateDataContext;
 import net.shibboleth.sp.ddf.DDF;
 import net.shibboleth.sp.profile.AbstractApplicationAction;
 import net.shibboleth.sp.profile.SPConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
 import net.shibboleth.sp.saml.saml2.context.SAMLLogoutContext;
 import net.shibboleth.sp.saml.saml2.profile.config.SingleLogoutProfileConfiguration;
+import net.shibboleth.sp.state.StateData;
 
 import org.opensaml.core.xml.XMLObjectBuilderFactory;
 import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
@@ -39,12 +42,12 @@ import org.opensaml.core.xml.io.MarshallingException;
 import org.opensaml.core.xml.io.UnmarshallingException;
 import org.opensaml.core.xml.util.XMLObjectSupport;
 import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.saml.common.SAMLObjectBuilder;
 import org.opensaml.saml.common.SAMLVersion;
-import org.opensaml.saml.common.binding.SAMLBindingSupport;
 import org.opensaml.saml.ext.saml2aslo.Asynchronous;
 import org.opensaml.saml.saml2.core.LogoutRequest;
 import org.opensaml.saml.saml2.core.Extensions;
@@ -83,6 +86,9 @@ public class AddLogoutRequest extends AbstractApplicationAction {
     /** Strategy used to locate the {@link IdentifierGenerationStrategy} to use. */
     @Nonnull private Function<ProfileRequestContext,IdentifierGenerationStrategy> idGeneratorLookupStrategy;
     
+    /** Strategy used to create the {@link StateDataContext} to populate. */
+    @Nonnull private Function<ProfileRequestContext,StateDataContext> stateDataContextCreationStrategy;
+    
     /** Strategy used to obtain the request issuer value. */
     @Nullable private Function<ProfileRequestContext,String> issuerLookupStrategy;
     
@@ -95,6 +101,9 @@ public class AddLogoutRequest extends AbstractApplicationAction {
     /** Cached logout context. */
     @NonnullBeforeExec private SAMLLogoutContext logoutContext;
     
+    /** Cached state data object to populate. */
+    @NonnullBeforeExec private SAMLStateData stateData;
+    
     /** EntityID to populate into Issuer element. */
     @Nullable private String issuerId;
     
@@ -102,7 +111,7 @@ public class AddLogoutRequest extends AbstractApplicationAction {
     public AddLogoutRequest() {
         // Default strategy is a 16-byte secure random source.
         idGeneratorLookupStrategy = new IdentifierGenerationStrategyLookupFunction();
-
+        stateDataContextCreationStrategy = new ChildContextLookup<>(StateDataContext.class, true);
         issuerLookupStrategy = new IssuerLookupFunction();
     }
         
@@ -128,6 +137,18 @@ public class AddLogoutRequest extends AbstractApplicationAction {
                 Constraint.isNotNull(strategy, "IdentifierGenerationStrategy lookup strategy cannot be null");
     }
 
+    /**
+     * Sets the strategy used to create the {@link StateDataContext}.
+     * 
+     * @param strategy creation strategy
+     */
+    public void setStateDataContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,StateDataContext> strategy) {
+        checkSetterPreconditions();
+        stateDataContextCreationStrategy =
+                Constraint.isNotNull(strategy, "StateDataContext creation strategy cannot be null");
+    }
+    
     /**
      * Set the strategy used to locate the issuer value to use.
      * 
@@ -181,6 +202,30 @@ public class AddLogoutRequest extends AbstractApplicationAction {
             return false;
         }
         
+        final StateDataContext stateDataContext = stateDataContextCreationStrategy.apply(profileRequestContext);
+        if (stateDataContext == null) {
+            log.error("{} Error creating StateDataContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        // Get target URL from either existing StateData or input message.
+        final DDF input = ensureAgentRequestContext().getInput();
+        byte[] target = input != null ? input.getmember(SPConstants.TARGET).unsafe_string() : null;
+        if (target == null) {
+            final StateData oldStateData = stateDataContext.getStateData();
+            if (oldStateData != null) {
+                target = oldStateData.getRawResource();
+            }
+        }
+        
+        // Establish fresh StateData of the correct type.
+        stateData = new SAMLStateData();
+        stateDataContext.setStateData(stateData);
+        assert rpCtx != null;
+        stateData.setAuthenticationAuthority(rpCtx.getRelyingPartyId());
+        stateData.setRawResource(target);
+        
         if (issuerLookupStrategy != null) {
             issuerId = issuerLookupStrategy.apply(profileRequestContext);
         }
@@ -207,6 +252,8 @@ public class AddLogoutRequest extends AbstractApplicationAction {
         // This might need to be configurable if the action is reused for some non-user purpose.
         object.setReason(LogoutRequest.USER_REASON);
 
+        stateData.setRequestID(object.getID());
+        
         if (issuerId != null) {
             log.debug("{} Setting Issuer to {}", getLogPrefix(), issuerId);
             final SAMLObjectBuilder<Issuer> issuerBuilder =
@@ -245,13 +292,6 @@ public class AddLogoutRequest extends AbstractApplicationAction {
         
         profileRequestContext.ensureOutboundMessageContext().setMessage(object);
         
-        // Check for RelayState.
-        final DDF input = ensureAgentRequestContext().getInput();
-        final String relayState = input != null ? input.getmember(SPConstants.STATE).string() : null;
-        if (relayState != null) {
-            SAMLBindingSupport.setRelayState(profileRequestContext.ensureOutboundMessageContext(), relayState);
-        }
-        
         log.info("{} Generated LogoutRequest with ID {} from {}", getLogPrefix(), object.getID(), issuerId);
     }
      
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PrepareAgentResponse.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PrepareAgentResponse.java
index be30ad1..77d67da 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PrepareAgentResponse.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PrepareAgentResponse.java
@@ -15,6 +15,7 @@
 package net.shibboleth.sp.saml.saml2.profile.impl;
 
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Map;
 import java.util.function.Function;
 
@@ -149,6 +150,13 @@ public class PrepareAgentResponse extends AbstractTokenConsumerResponseAction {
         
         return null;
     }
+    
+    /** {@inheritDoc} */
+    @Override
+    @Nullable protected Instant getSessionNotOnOrAfter(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final AuthnStatement statement = samlTokenContext.getAuthnStatement();
+        return statement != null ? statement.getSessionNotOnOrAfter() : null;
+    }    
 
     static {
         NO_XML_DECL_PARAMS = CollectionSupport.<String,Object>singletonMap("xml-declaration", Boolean.FALSE);
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PreserveRelayState.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PreserveRelayState.java
new file mode 100644
index 0000000..366296b
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/PreserveRelayState.java
@@ -0,0 +1,37 @@
+/*
+ * 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 net.shibboleth.sp.saml.saml2.profile.impl;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.saml.common.binding.SAMLBindingSupport;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.sp.profile.PreserveStateDataAction;
+
+/**
+ * SAML-specific action that processes the state token by setting it as the RelayState value.
+ */
+public class PreserveRelayState extends PreserveStateDataAction {
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void processToken(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull @NotEmpty final String token) {
+        SAMLBindingSupport.setRelayState(profileRequestContext.ensureOutboundMessageContext(), token);
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessAssertionsForAuthentication.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessAssertionsForAuthentication.java
new file mode 100644
index 0000000..45d8ce8
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ProcessAssertionsForAuthentication.java
@@ -0,0 +1,385 @@
+/*
+ * 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 net.shibboleth.sp.saml.saml2.profile.impl;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.saml.common.assertion.ValidationContext;
+import org.opensaml.saml.common.assertion.ValidationProcessingData;
+import org.opensaml.saml.common.assertion.ValidationResult;
+import org.opensaml.saml.saml2.assertion.SAML2AssertionValidationParameters;
+import org.opensaml.saml.saml2.core.Assertion;
+import org.opensaml.saml.saml2.core.AuthnContext;
+import org.opensaml.saml.saml2.core.AuthnContextClassRef;
+import org.opensaml.saml.saml2.core.AuthnContextComparisonTypeEnumeration;
+import org.opensaml.saml.saml2.core.AuthnStatement;
+import org.opensaml.saml.saml2.core.Response;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.RequestedPrincipalContext;
+import net.shibboleth.idp.authn.principal.PrincipalEvalPredicateFactoryRegistry;
+import net.shibboleth.idp.saml.authn.principal.AuthnContextClassRefPrincipal;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.sp.context.StateDataContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.profile.ConsumerConstants;
+import net.shibboleth.sp.saml.saml2.SAMLStateData;
+
+/**
+ * Perform processing of a SAML 2 Response's Assertions that have been validated by earlier actions
+ * for use in finalization of SAML-based authentication by later actions.
+ * 
+ * <p>The result of this action is to strip any invalid assertions from the response, and to preserve
+ * the "best"/selected {@link AuthnStatement} and any other content required in a pluggable manner.</p>
+ * 
+ * <p>This is a copy of an IdP action for the time being as there was no way to override the desired
+ * behavior.</p>
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ * @post the selected statement is passed into the supplied {@link BiConsumer}
+ */
+public class ProcessAssertionsForAuthentication extends AbstractApplicationAction {
+    
+    /** Logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(ProcessAssertionsForAuthentication.class);
+    
+    /** The resolver for the response to be processed. */
+    @NonnullAfterInit private Function<ProfileRequestContext,Response> responseResolver;
+    
+    /** "Sink" for preserving SAML objects. */
+    @NonnullAfterInit private BiConsumer<ProfileRequestContext,AuthnStatement> samlConsumer;
+    
+    /** Strategy used to locate the {@link StateDataContext} to check. */
+    @Nonnull private Function<ProfileRequestContext,StateDataContext> stateDataContextLookupStrategy;
+    
+    /** The registry of predicate factories for custom principal evaluation. */
+    @NonnullBeforeExec private PrincipalEvalPredicateFactoryRegistry evalRegistry;
+    
+    /** The Response to process. */
+    @NonnullBeforeExec private Response response;
+    
+    /** State data to validate. */
+    @NonnullBeforeExec private SAMLStateData stateData;
+    
+    /**
+     * Constructor.
+     */
+    public ProcessAssertionsForAuthentication() {
+        stateDataContextLookupStrategy = new ChildContextLookup<>(StateDataContext.class);
+    }
+
+    /**
+     * Set the strategy function which resolves the response to process.
+     * 
+     * @param strategy the new strategy function
+     */
+    public void setResponseResolver(@Nonnull final Function<ProfileRequestContext, Response> strategy) {
+        checkSetterPreconditions();
+        responseResolver = Constraint.isNotNull(strategy, "Response resolver cannot be null");
+    }
+    
+    /**
+     * Set the {@link BiConsumer} used to save off the SAML statemen and any related objects as a result of this action.
+     * 
+     * <p>This insulates the actiion from the specific context in which it may be used. The supplied consumer
+     * <strong>MUST</strong> establish any non-successful event via the supplied context if it fails.</p>
+     * 
+     * @param consumer consumer to set
+     */
+    public void setSAMLConsumer(@Nonnull final BiConsumer<ProfileRequestContext,AuthnStatement> consumer) {
+        checkSetterPreconditions();
+        samlConsumer = Constraint.isNotNull(consumer, "BiConsumer cannot be null");
+    }
+
+    /**
+     * Sets the strategy used to lookup the {@link StateDataContext}.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setStateDataContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,StateDataContext> strategy) {
+        checkSetterPreconditions();
+        stateDataContextLookupStrategy =
+                Constraint.isNotNull(strategy, "StateDataContext creation strategy cannot be null");
+    }
+    
+    /**
+     * Set the registry of predicate factories for custom principal evaluation.
+     * 
+     * @param registry predicate factory registry
+     */
+    public void setPrincipalEvalPredicateFactoryRegistry(
+            @Nonnull final PrincipalEvalPredicateFactoryRegistry registry) {
+        
+        evalRegistry = Constraint.isNotNull(registry, "PrincipalEvalPredicateFactoryRegistry cannot be null");
+    }
+        
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (responseResolver == null) {
+            throw new ComponentInitializationException("Response resolver cannot be null");
+        } else if (samlConsumer == null) {
+            throw new ComponentInitializationException("BiConsumer cannot be null");
+        } else if (evalRegistry == null) {
+            throw new ComponentInitializationException("PrincipalEvalPredicateFactoryRegistry cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        response = responseResolver.apply(profileRequestContext);
+        if (response == null || response.getAssertions().isEmpty()) {
+            log.info("{} Profile context contained no candidate Assertions to process. Skipping further processing",
+                    getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_CREDENTIALS);
+            return false;
+        }
+
+        final StateDataContext stateDataContext = stateDataContextLookupStrategy.apply(profileRequestContext);
+        if (stateDataContext != null && stateDataContext.getStateData() instanceof SAMLStateData samlState) {
+            stateData = samlState;
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        // Completely remove any non-valid Assertions from the Response
+        final List<Assertion> nonValid = response.getAssertions().stream()
+                .filter(new AssertionIsValid().negate())
+                .collect(Collectors.toList());
+        log.debug("{} Removing {} non-valid Assertions from Response", getLogPrefix(), nonValid.size());
+        response.getAssertions().removeAll(nonValid);
+
+        // For authn purposes, select only Assertions which contain at least 1 AuthnStatement and a confirmed Subject
+        final Predicate<Assertion> selector = new AssertionContainsAuthenticationStatement()
+                .and(new AssertionContainsConfirmedSubject());
+                
+        final List<Assertion> assertions = response.getAssertions().stream()
+                .filter(selector)
+                .collect(Collectors.toList());
+        if (assertions.isEmpty()) {
+            log.info("{} No valid SAML Assertions suitable for authentication were found", getLogPrefix());
+            addValidationError("No valid assertions suitable for authentication were found");
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+            return;
+        }
+        
+        // Of the remaining, we need to find the {@link AuthnStatement} with the earliest
+        // {@link AuthnStatement#getSessionNotOnOrAfter()} value and that optionally supplies an
+        // acceptable {@link AuthnContext} in the event the original request has specific demands.
+        
+        AuthnStatement authnStatement = null;
+        Assertion authnAssertion = null;
+        
+        RequestedPrincipalContext helperContext = null;
+        
+        if (stateData != null && !stateData.getAcrs().isEmpty()) {
+            
+            // Using the RequestedPrincipalContext reuses a lot of low level IdP machinery for us,
+            // we just have to transform the request state into a list of Principals.
+            
+            helperContext = new RequestedPrincipalContext();
+            
+            final String operator = stateData.getAuthnContextOperator();
+            
+            final List<Principal> accumulator = new ArrayList<>(stateData.getAcrs().size());
+            stateData.getAcrs().stream().map(AuthnContextClassRefPrincipal::new).forEach(accumulator::add);
+            
+            helperContext
+                .setPrincipalEvalPredicateFactoryRegistry(evalRegistry)
+                .setOperator(operator != null ? operator : AuthnContextComparisonTypeEnumeration.EXACT.toString())
+                .setRequestedPrincipals(accumulator);
+        }
+        
+        for (final Assertion assertion : assertions) {
+            for (final AuthnStatement statement : assertion.getAuthnStatements()) {
+                if (helperContext == null || isAcceptable(helperContext, statement.getAuthnContext())) {
+                    if (authnStatement == null) {
+                        authnStatement = statement;
+                        authnAssertion = assertion;
+                    } else {
+                        final Instant newFence = statement.getSessionNotOnOrAfter();
+                        final Instant oldFence = authnStatement.getSessionNotOnOrAfter();
+                        if (newFence != null && (oldFence == null || newFence.isBefore(oldFence))) {
+                            authnStatement = statement;
+                            authnAssertion = assertion;
+                        }
+                    }
+                }
+            }
+        }
+        
+        if (authnAssertion == null) {
+            log.info("{} Could not select a single valid SAML Assertion for authentication", getLogPrefix());
+            addValidationError("No suitable AuthnContextClassRef found");
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+            return;
+        }
+
+        log.debug("{} Selected statement from Assertion {} for authentication", getLogPrefix(), authnAssertion.getID());
+                
+        samlConsumer.accept(profileRequestContext, authnStatement);
+    }
+    
+    /**
+     * Process an {@link AuthnContext} for compatibility with the request as brokered by the populated
+     * {@link RequestedPrincipalContext}.
+     * 
+     * @param helperContext populated context to drive context evaluation
+     * @param authnContext input context object from assertion statement
+     * 
+     * @return true iff the context carries an ACR that is compatible with the request
+     */
+    private boolean isAcceptable(@Nonnull final RequestedPrincipalContext helperContext,
+            @Nullable final AuthnContext authnContext) {
+        
+        final AuthnContextClassRef acr = authnContext != null ? authnContext.getAuthnContextClassRef() : null;
+        final String classRef = acr != null ? acr.getURI() : null;
+        if (classRef == null) {
+            log.debug("Statement's AuthnContext did not contain an AuthnContextClassRef, not valid", getLogPrefix());
+            return false;
+        }
+
+        if (helperContext.isAcceptable(new AuthnContextClassRefPrincipal(classRef))) {
+            log.debug("{} AuthnContextClassRef {} satisfied request", getLogPrefix(), classRef);
+            return true;
+        } else {
+            log.warn("{} AuthnContextClassRef {} did not satisfy request", getLogPrefix(), classRef);
+            return false;
+        }
+    }
+    
+    /**
+     * Adds a validation error for the agent regarding the failure.
+     * 
+     * @param msg error message
+     */
+    private void addValidationError(@Nonnull final String msg) {
+        DDF output = ensureAgentRequestContext().getOutput();
+        if (output == null) {
+            output = new DDF(null).structure();
+            ensureAgentRequestContext().setOutput(output);
+        }
+        
+        final DDF errors = output.addmember(ConsumerConstants.VALIDATION_ERRORS);
+        if (!errors.islist()) {
+            errors.list();
+        }
+        
+        errors.add(new DDF(null).string(msg));
+    }
+    
+    /**
+     * Predicate for valid assertions.
+     */
+    private final class AssertionIsValid implements Predicate<Assertion> {
+
+        /** {@inheritDoc} */
+        public boolean test(@Nullable final Assertion assertion) {
+            if (assertion == null) {
+                return false;
+            }
+            
+            final Optional<ValidationProcessingData> validationData = assertion.getObjectMetadata()
+                    .get(ValidationProcessingData.class).stream().findFirst();
+            if (validationData.isEmpty()) {
+                return false;
+            }
+            
+            return validationData.get().getResult() == ValidationResult.VALID;
+        }
+        
+    }
+        
+    /**
+     * Predicate for assertions containing at least 1 AuthenticationStatement.
+     */
+    private final class AssertionContainsAuthenticationStatement implements Predicate<Assertion> {
+
+        /** {@inheritDoc} */
+        public boolean test(@Nullable final Assertion assertion) {
+            if (assertion == null) {
+                return false;
+            }
+            
+            return ! assertion.getAuthnStatements().isEmpty();
+        }
+        
+    }
+
+    /**
+     * Predicate for assertions which have been validated and have a confirmed Subject.
+     */
+    private final class AssertionContainsConfirmedSubject implements Predicate<Assertion> {
+
+        /** {@inheritDoc} */
+        @SuppressWarnings("unused")
+        public boolean test(@Nullable final Assertion assertion) {
+            if (assertion == null) {
+                return false;
+            }
+            
+            final Optional<ValidationProcessingData> validationData = assertion.getObjectMetadata()
+                    .get(ValidationProcessingData.class).stream().findFirst();
+            if (validationData.isEmpty()) {
+                return false;
+            }
+            
+            final ValidationContext validationContext = validationData.get().getContext();
+            if (validationContext == null) {
+                return false;
+            }
+            
+            return validationContext.getDynamicParameters()
+                    .get(SAML2AssertionValidationParameters.CONFIRMED_SUBJECT_CONFIRMATION) != null;
+        }
+        
+    }
+
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list