[java-plugin-shibd] branch main updated: Add POST data recovery action and initial unit tests.

Scott Cantor cantor.2 at osu.edu
Fri Oct 3 13:50:09 UTC 2025


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

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

View the commit online:
http://git.shibboleth.net/view/?p=java-plugin-shibd.git;a=commit;h=4b348370eeb931e80db60d7d4fbe544a20109f8d

The following commit(s) were added to refs/heads/main by this push:
     new 4b34837  Add POST data recovery action and initial unit tests.
4b34837 is described below

commit 4b348370eeb931e80db60d7d4fbe544a20109f8d
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Fri Oct 3 09:50:02 2025 -0400

    Add POST data recovery action and initial unit tests.
---
 .../src/main/java/net/shibboleth/sp/Agent.java     |  10 +
 .../sp/messaging/RemotedHttpServletRequest.java    |  69 ++--
 .../sp/messaging/RemotedHttpServletResponse.java   |   8 +-
 .../AbstractTokenConsumerResponseAction.java       |  53 ++-
 .../java/net/shibboleth/sp/impl/BasicAgent.java    |  20 ++
 .../sp/profile/impl/PreservePostData.java          |  13 +-
 .../sp/profile/impl/RecoverPostData.java           | 389 +++++++++++++++++++++
 .../templates/sp/add-html-body-content.vm          |   2 +
 .../templates/sp/add-html-head-content.vm          |   2 +
 .../src/main/resources/templates/sp/post-replay.vm |  54 +++
 .../sp/profile/impl/PreservePostDataTest.java      |  12 +-
 .../sp/profile/impl/RecoverPostDataTest.java       | 236 +++++++++++++
 sp-server-impl/src/test/resources/logback-test.xml |  17 +
 13 files changed, 805 insertions(+), 80 deletions(-)

diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java b/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
index 5cce943..b46828b 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/Agent.java
@@ -16,6 +16,7 @@
 package net.shibboleth.sp;
 
 import java.net.InetAddress;
+import java.nio.charset.Charset;
 import java.util.Collection;
 import java.util.Set;
 
@@ -109,6 +110,15 @@ public interface Agent extends IdentifiedComponent {
      * @return size limit for form data preservation
      */
     @NonNegative long getPostLimit();
+    
+    /**
+     * Gets the character encoding to apply during POST recovery.
+     * 
+     * <p>Defaults to UTF-8.</p>
+     * 
+     * @return name of encoding
+     */
+    @Nonnull Charset getCharacterEncoding();
 
     /**
      * Get an {@link Application} associated with this agent.
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
index 5038e18..4d8dbd2 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletRequest.java
@@ -116,15 +116,12 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
                 .onMalformedInput(CodingErrorAction.REPORT)
                 .onUnmappableCharacter(CodingErrorAction.REPORT);
 
-    /** ISO single byte decoder. */
-    @Nonnull private static final CharsetDecoder ISO_8859_1 =
-            StandardCharsets.ISO_8859_1.newDecoder()
-                .onMalformedInput(CodingErrorAction.REPORT)
-                .onUnmappableCharacter(CodingErrorAction.REPORT);
-
     /** Underlying object containing remoted data. */
     @Nonnull private final DDF obj;
     
+    /** Decoder to apply to byte array data. */
+    @Nonnull private final CharsetDecoder decoder;
+    
     /** Cookie array. */
     @NonnullElements private List<Cookie> cookies;
     
@@ -132,12 +129,23 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
     @Nullable private Map<String, String[]> parameters;
     
     /**
-     * Constructor.
+     * Constructor for UTF-8 usage.
      *
      * @param ddf remoted request information
      */
     public RemotedHttpServletRequest(@Nonnull final DDF ddf) {
+        this(ddf, UTF_8);
+    }
+    
+    /**
+     * Constructor.
+     *
+     * @param ddf remoted request information
+     * @param dec a backup decoder if UTF-8 fails
+     */
+    public RemotedHttpServletRequest(@Nonnull final DDF ddf, @Nonnull CharsetDecoder dec) {
         obj = Constraint.isNotNull(ddf, "DDF cannot be null");
+        decoder = Constraint.isNotNull(dec, "CharsetDecoder cannot be null");
     }
     
     /**
@@ -233,7 +241,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
                 final byte[] body = obj.getmember("body").unsafe_string();
                 if (body != null) {
                     final List<Pair<String,String>> qparams =
-                            URISupport.parseQueryString(decodeUnsafeString(body));
+                            URISupport.parseQueryString(decodeUnsafeString(body, decoder, null));
                     for (final Pair<String,String> p : qparams) {
                         multimap.put(p.getFirst(), p.getSecond());
                     }
@@ -278,7 +286,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
 
     /** {@inheritDoc} */
     public String getServerName() {
-        return decodeUnsafeString(obj.getmember("hostname").unsafe_string());
+        return decodeUnsafeString(obj.getmember("hostname").unsafe_string(), decoder, null);
     }
 
     /** {@inheritDoc} */
@@ -292,7 +300,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         return new BufferedReader(
                 new InputStreamReader(
                         new ByteArrayInputStream(
-                                obj.getmember(BODY).unsafe_string()), StandardCharsets.UTF_8));
+                                obj.getmember(BODY).unsafe_string()), decoder.charset()));
     }
 
     /** {@inheritDoc} */
@@ -459,7 +467,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         if (name == null) {
             return null;
         }
-        return decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string());
+        return decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string(), decoder, null);
     }
 
     /** {@inheritDoc} */
@@ -467,7 +475,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         if (name == null) {
             return Collections.emptyEnumeration();
         }
-        final String s = decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string());
+        final String s = decodeUnsafeString(obj.getmember("headers").getmember(name).unsafe_string(), decoder, null);
         if (s != null) {
             return Collections.enumeration(Collections.singletonList(s));
         }
@@ -486,7 +494,7 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         }
         final DDF h = obj.getmember("headers").getmember(name);
         if (h.isstring()) {
-            return Integer.parseInt(decodeUnsafeString(h.unsafe_string()));
+            return Integer.parseInt(decodeUnsafeString(h.unsafe_string(), decoder, null));
         }
         return -1;
     }
@@ -540,12 +548,12 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
 
     /** {@inheritDoc} */
     public String getRequestURI() {
-        return decodeUnsafeString(obj.getmember(REQUEST_URI).unsafe_string());
+        return decodeUnsafeString(obj.getmember(REQUEST_URI).unsafe_string(), decoder, '?');
     }
 
     /** {@inheritDoc} */
     public StringBuffer getRequestURL() {
-        final String url = decodeUnsafeString(obj.getmember(REQUEST_URL).unsafe_string());
+        final String url = decodeUnsafeString(obj.getmember(REQUEST_URL).unsafe_string(), decoder, '?');
         return new StringBuffer(url != null ? url : "");
     }
 
@@ -619,13 +627,16 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
     }
     
     /**
-     * Helper method to decode a byte buffer into either UTF-8 or ISO-8859-1.
+     * Helper method to decode a byte array via a designated encoding.
      * 
      * @param buffer input buffer
+     * @param decoder decoder to apply
+     * @param delim optional delimter to truncate at
      * 
-     * @return encoded String form of the data
+     * @return decoded String form of the data or null if a decoding exception was caught
      */
-    @Nullable private static String decodeUnsafeString(final byte[] buffer) {
+    @Nullable public static String decodeUnsafeString(final byte[] buffer, @Nonnull final CharsetDecoder decoder,
+            @Nullable final Character delim) {
         
         if (buffer == null) {
             return null;
@@ -634,27 +645,15 @@ public class RemotedHttpServletRequest implements HttpServletRequest {
         final ByteBuffer wrapper = ByteBuffer.wrap(buffer);
 
         try {
-            final String decoded = UTF_8.decode(wrapper).toString();
-            final int delim = decoded.indexOf('?');
-            if (delim > 0) {
-                return decoded.substring(0, delim);
+            final String decoded = decoder.decode(wrapper).toString();
+            final int index = delim != null ? decoded.indexOf(delim) : 0;
+            if (index > 0) {
+                return decoded.substring(0, index);
             } else {
                 return decoded;
             }
         } catch (final CharacterCodingException e) {
-            
-        }
-        
-        try {
-            final String decoded = ISO_8859_1.decode(wrapper).toString();
-            final int delim = decoded.indexOf('?');
-            if (delim > 0) {
-                return decoded.substring(0, delim);
-            } else {
-                return decoded;
-            }
-        } catch (final CharacterCodingException e) {
-            
+
         }
         
         return null;
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletResponse.java b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletResponse.java
index 21d3b66..2a6d1c5 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletResponse.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/messaging/RemotedHttpServletResponse.java
@@ -53,9 +53,15 @@ public class RemotedHttpServletResponse implements HttpServletResponse {
     /** Field holding redirect location. */
     @Nonnull @NotEmpty public static final String REDIRECT = "redirect";
 
-    /** Field holding response data. */
+    /** Field holding response. */
     @Nonnull @NotEmpty public static final String RESPONSE = "response";
 
+    /** Field holding response body data. */
+    @Nonnull @NotEmpty public static final String DATA = "data";
+
+    /** Field holding response body status. */
+    @Nonnull @NotEmpty public static final String STATUS = "status";
+    
     /** Field holding header collection. */
     @Nonnull @NotEmpty public static final String HEADERS = "headers";
 
diff --git a/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractTokenConsumerResponseAction.java b/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractTokenConsumerResponseAction.java
index 5077cb3..619c694 100644
--- a/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractTokenConsumerResponseAction.java
+++ b/sp-server-api/src/main/java/net/shibboleth/sp/profile/AbstractTokenConsumerResponseAction.java
@@ -56,6 +56,9 @@ import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
  * redirect to the recovered URL, the serialized attribute data, and the opaque session
  * data.</p>
  * 
+ * <p>If the response is "committed" prior to this action, then the redirect portion will
+ * be skipped in favor of the previous response.</p>
+ * 
  * @event {@link EventIds#PROCEED_EVENT_ID}
  */
 public abstract class AbstractTokenConsumerResponseAction extends AbstractApplicationAction {
@@ -69,6 +72,7 @@ public abstract class AbstractTokenConsumerResponseAction extends AbstractApplic
     /** Constructor. */
     public AbstractTokenConsumerResponseAction() {
         attributeContextLookupStrategy = new ChildContextLookup<>(AttributeContext.class);
+        setCreateOutputObjects(true);
     }
 
     /**
@@ -87,19 +91,11 @@ public abstract class AbstractTokenConsumerResponseAction extends AbstractApplic
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
         
+        ensureOutputObjects();
+
         final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
-        
-        DDF output = agentRequestContext.getOutput();
-        if (output == null) {
-            output = new DDF(null).structure();
-            agentRequestContext.setOutput(output);
-        }
-        
-        DDF httpResponse = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME);
-        if (!httpResponse.isstruct() || agentRequestContext.getRemotedHttpServletResponse() == null) {
-            httpResponse = output.addmember(RemotedHttpServletResponse.STRUCTURE_NAME);
-            agentRequestContext.setRemotedHttpServletResponse(new RemotedHttpServletResponse(httpResponse));
-        }
+        final DDF output = agentRequestContext.getOutput();
+        assert output != null;
         
         final AttributeContext attributeContext = attributeContextLookupStrategy.apply(profileRequestContext);
         if (attributeContext != null && !attributeContext.getIdPAttributes().isEmpty()) {
@@ -138,27 +134,30 @@ public abstract class AbstractTokenConsumerResponseAction extends AbstractApplic
             output.addmember(ConsumerConstants.SESSION_OPAQUE).string(sessionData);
         }
         
-        // Issue redirect to proper resource URL. Either recovered from protocol state earlier
-        // or falling back to an input parameter from the agent. Final backstop is a relative
-        // redirect to the site root.
-        
         final RemotedHttpServletResponse remotedResponse = agentRequestContext.getRemotedHttpServletResponse();
         assert remotedResponse != null;
         
-        byte[] resource = agentRequestContext.getTargetURL();
-        if (resource == null || resource.length == 0) {
-            final DDF in = agentRequestContext.getInput();
-            if (in != null) {
-                resource = in.getmember(ConsumerConstants.BASE_URL).unsafe_string();
-            }
+        if (!remotedResponse.isCommitted()) {
+            // Issue redirect to proper resource URL. Either recovered from protocol state earlier
+            // or falling back to an input parameter from the agent. Final backstop is a relative
+            // redirect to the site root.
+            
+            
+            byte[] resource = agentRequestContext.getTargetURL();
             if (resource == null || resource.length == 0) {
-                resource = "/".getBytes(StandardCharsets.UTF_8);
+                final DDF in = agentRequestContext.getInput();
+                if (in != null) {
+                    resource = in.getmember(ConsumerConstants.BASE_URL).unsafe_string();
+                }
+                if (resource == null || resource.length == 0) {
+                    resource = "/".getBytes(StandardCharsets.UTF_8);
+                }
+                agentRequestContext.setTargetURL(resource);
             }
-            agentRequestContext.setTargetURL(resource);
+            
+            output.addmember(SPConstants.TARGET).unsafe_string(resource);
+            remotedResponse.sendRedirect(resource);
         }
-        
-        output.addmember(SPConstants.TARGET).unsafe_string(resource);
-        remotedResponse.sendRedirect(resource);
     }
      
     /**
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
index 35e0c1e..8070f6b 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/impl/BasicAgent.java
@@ -16,6 +16,8 @@
 package net.shibboleth.sp.impl;
 
 import java.net.InetAddress;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Map;
@@ -59,6 +61,9 @@ public class BasicAgent extends BasicApplication implements Agent {
     /** Limit on size of form data to preserve. */
     private long postLimit;
     
+    /** Character set encoding for POST recovery. */
+    @Nonnull private Charset encoding;
+    
     /** Internally configured shared secrets. */
     @Nonnull private Set<String> sharedSecrets;
         
@@ -72,6 +77,7 @@ public class BasicAgent extends BasicApplication implements Agent {
         authenticationMethod  = null;
         supportsPostPreservation = true;
         postLimit = 1024 * 1024;
+        encoding = StandardCharsets.UTF_8;
         sharedSecrets = CollectionSupport.emptySet();
         applicationMap = CollectionSupport.emptyMap();
         
@@ -193,6 +199,20 @@ public class BasicAgent extends BasicApplication implements Agent {
         postLimit = Constraint.isGreaterThanOrEqual(0, limit, "Post limit cannot be negative.");
     }
     
+    /** {@inheritDoc} */
+    @Nonnull public Charset getCharacterEncoding() {
+        return encoding;
+    }
+    
+    /**
+     * Sets the character encoding to apply for POST recovery.
+     * 
+     * @param name name of encoding
+     */
+    public void CharacterEncoding(@Nonnull final String name) {
+        encoding = Charset.forName(name);
+    }
+    
     /**
      * Sets the {@link Application} instances associated with this agent.
      * 
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java
index 26efe23..e63421c 100644
--- a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/PreservePostData.java
@@ -15,6 +15,7 @@
 package net.shibboleth.sp.profile.impl;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.time.Instant;
 
@@ -33,8 +34,6 @@ import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.annotation.constraint.Positive;
-import net.shibboleth.shared.codec.Base64Support;
-import net.shibboleth.shared.codec.EncodingException;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.net.CookieManager;
@@ -270,8 +269,9 @@ public class PreservePostData extends AbstractApplicationAction {
             log.debug("{} Preserving {} bytes of POST data against state token {}", getLogPrefix(), postData.length,
                     stateToken);
             
-            // Encode the data for storage.
-            final String encoded = Base64Support.encode(postData, false);
+            // Given the MIME type, we assume the POST data is URL-encoded, ergo safe to translate as UTF-8.
+            // At this stage the underlying data may not in fact be UTF-8 but the encoded characters are ASCII.
+            final String encoded = new String(postData, StandardCharsets.UTF_8);
             
             // Generate a storage key.
             final String key = identifierStrategy.generateIdentifier(false);
@@ -293,11 +293,6 @@ public class PreservePostData extends AbstractApplicationAction {
             final Escaper escaper = UrlEscapers.urlFormParameterEscaper();
             cookieManager.addCookie(cookiePrefix + escaper.escape(stateToken), escaper.escape(key));
             
-        } catch (final EncodingException e) {
-            log.error("{} Error base64-encoding data", getLogPrefix(), e);
-            if (errorFatal) {
-                ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
-            }
         } catch (final IOException e) {
             log.error("{} Error creating storage record for POST data", getLogPrefix(), e);
             if (errorFatal) {
diff --git a/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/RecoverPostData.java b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/RecoverPostData.java
new file mode 100644
index 0000000..0ff8aaf
--- /dev/null
+++ b/sp-server-impl/src/main/java/net/shibboleth/sp/profile/impl/RecoverPostData.java
@@ -0,0 +1,389 @@
+/*
+ * 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.profile.impl;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.app.VelocityEngine;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.HttpServletResponse;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.codec.HTMLEncoder;
+import net.shibboleth.shared.codec.StringDigester;
+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.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.servlet.HttpServletSupport;
+import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequestResponseContext;
+import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Action that detects submitted form data, and when permitted, stores it in a {@link StorageService} and
+ * issues a cookie associated with the active state token to preserve a pointer to the data for recovery.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#IO_ERROR}
+ */
+public class RecoverPostData extends AbstractApplicationAction {
+    
+    /** Default template ID. */
+    @Nonnull @NotEmpty static public final String DEFAULT_TEMPLATE_ID = "/templates/sp/post-replay.vm";
+    
+    /** Default cookie prefix. */
+    @Nonnull @NotEmpty static public final String DEFAULT_COOKIE_PREFIX = "_shibsp_post_";
+    
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(RecoverPostData.class);
+    
+    /** Velocity engine used to evaluate the replay form template. */
+    @NonnullAfterInit private VelocityEngine velocityEngine;
+
+    /** ID of the velocity template used to generate replay form. */
+    @NonnullAfterInit private String velocityTemplateId;
+    
+    /** Digester for CSP hashes. */
+    @Nullable private StringDigester cspDigester;
+    
+    /** CSP nonce generation. */
+    @Nullable private IdentifierGenerationStrategy cspNonceGenerator;
+    
+    /** Storage service for data. */
+    @NonnullAfterInit private StorageService storageService;
+    
+    /** Cookie manager. */
+    @NonnullAfterInit private CookieManager cookieManager;
+    
+    /** Cookie prefix. */
+    @Nonnull private String cookiePrefix;
+
+    /** Deecoder for Agent data. */
+    @NonnullBeforeExec private CharsetDecoder decoder;
+    
+    /** Recovered data. */
+    @NonnullBeforeExec private String postData;
+    
+    /** Constructor. */
+    public RecoverPostData() {
+        velocityTemplateId = DEFAULT_TEMPLATE_ID;
+        cookiePrefix = DEFAULT_COOKIE_PREFIX;
+        setCreateOutputObjects(true);
+    }    
+    
+    /**
+     * Set the VelocityEngine instance.
+     * 
+     * @param newVelocityEngine the new VelocityEngine instane
+     */
+    public void setVelocityEngine(@Nullable final VelocityEngine newVelocityEngine) {
+        checkSetterPreconditions();
+        velocityEngine = newVelocityEngine;
+    }
+
+    /**
+     * Set the Velocity template ID.
+     * 
+     * <p>Defaults to {@link #DEFAULT_TEMPLATE_ID}.</p>
+     * 
+     * @param newVelocityTemplateId the new Velocity template id
+     */
+    public void setVelocityTemplateId(@Nullable final String newVelocityTemplateId) {
+        checkSetterPreconditions();
+        velocityTemplateId = newVelocityTemplateId;
+    }
+    
+    /**
+     * Set a {@link StringDigester} to use to generate CSP hashes.
+     * 
+     * @param digester string digester
+     */
+    public void setCSPDigester(@Nullable final StringDigester digester) {
+        checkSetterPreconditions();
+        cspDigester = digester;
+    }
+    
+    /**
+     * Set {@link IdentifierGenerationStrategy} to use for generating CSP nonces.
+     * 
+     * @param strategy nonce generator strategy
+     */
+    public void setCSPNonceGenerator(@Nullable final IdentifierGenerationStrategy strategy) {
+        checkSetterPreconditions();
+        
+        cspNonceGenerator = Constraint.isNotNull(strategy, "IdentifierGenerationStrategy cannot be null");
+    }
+        
+    /**
+     * Set {@link StorageService} to use.
+     * 
+     * @param storage storage service
+     */
+    public void setStorageService(@Nonnull final StorageService storage) {
+        checkSetterPreconditions();
+        
+        storageService = Constraint.isNotNull(storage, "StorageService cannot be null");
+    }
+    
+    /**
+     * Sets the {@link CookieManager} to use.
+     * 
+     * @param manager cookie manager instance
+     */
+    public void setCookieManager(@Nonnull final CookieManager manager) {
+        checkSetterPreconditions();
+        
+        cookieManager = Constraint.isNotNull(manager, "CookieManager cannot be null");
+    }
+    
+    /**
+     * Sets the cookie prefix.
+     * 
+     * <p>Defaults to {@link #DEFAULT_COOKIE_PREFIX}.</p>
+     * 
+     * @param prefix cookie prefix
+     */
+    public void setCookiePrefix(@Nonnull @NotEmpty final String prefix) {
+        checkSetterPreconditions();
+        
+        cookiePrefix = Constraint.isNotNull(StringSupport.trimOrNull(prefix), "Cookie prefix cannot be null or empty");
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (velocityEngine == null) {
+            throw new ComponentInitializationException("VelocityEngine cannot be null");
+        }
+        
+        if (velocityTemplateId == null) {
+            throw new ComponentInitializationException("Velocity template ID cannot be null");
+        }
+        
+        if (cookieManager == null) {
+            throw new ComponentInitializationException("CookieManager cannot be null");
+        } else if (storageService == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        } else if (!storageService.getCapabilities().isServerSide()) {
+            throw new ComponentInitializationException("StorageService cannot be client-side");
+        }
+    }    
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        
+        ensureOutputObjects();
+        
+        // Masks any errors with a null return. We do this up front so that any data is cleaned
+        // up even in the event we abort below.
+        postData = getRecoveredData();
+        if (postData == null) {
+            return false;
+        }
+        
+        // Check permission.
+        
+        if (!ensureAgent().isSupportsPostPreservation()) {
+            log.warn("{} POST data preservation not permitted for agent", getLogPrefix());
+            return false;
+        }
+        
+        // See if we know the target URL. If not, a fallback to homeURL or the site root is
+        // not an appropriate target for a POST. 
+        if (ensureAgentRequestContext().getTargetURL() == null) {
+            log.warn("{} No definitive target resource, POST data recovery aborted", getLogPrefix());
+            return false;
+        }
+        
+        decoder = ensureAgent().getCharacterEncoding().newDecoder()
+                .onMalformedInput(CodingErrorAction.REPORT)
+                .onUnmappableCharacter(CodingErrorAction.REPORT);
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
+
+        // Try and decode the data as best we can.
+        final String decodedTarget = RemotedHttpServletRequest.decodeUnsafeString(
+                ensureAgentRequestContext().getTargetURL(), decoder, null);
+        if (decodedTarget == null) {
+            log.warn("{} Failure decoding target resource byte array using encoding: {}", getLogPrefix(),
+                    decoder.charset().name());
+            return;
+        }
+        
+        // Parse the URL-encoded POST data using the agent's character set.
+        // In practice this isn't totally safe because Java's URL decoder will do
+        // character substitutions in the face of illegal encodings, but it's better
+        // than nothing in that at least correctly encoded data will be decoded correctly
+        // into Unicode.
+        final List<Pair<String,String>> params;
+        try {
+            params = URISupport.parseQueryString(postData, true, true, decoder.charset());
+        } catch (final IllegalArgumentException e) {
+            log.warn("{} Failure decoding form parameters using encoding: {}", getLogPrefix(),
+                    decoder.charset().name());
+            return;
+        }
+        
+        // HTML-encode the parameters in place so they're ready for insertion in the form by the template.
+        for (final Pair<String,String> param : params) {
+            param.setFirst(HTMLEncoder.encodeForHTMLAttribute(param.getFirst()));
+            param.setSecond(HTMLEncoder.encodeForHTMLAttribute(param.getSecond()));
+        }
+        
+        // We do the crazy stuff to catch the template output.
+        try {
+            RemotedHttpServletRequestResponseContext.loadCurrent(agentRequestContext.getRemotedHttpServletRequest(),
+                    agentRequestContext.getRemotedHttpServletResponse());
+            
+            log.debug("{} Recovering {} characters of POST data into form template", getLogPrefix(), postData.length());
+
+            final VelocityContext context = new VelocityContext();
+
+            if (cspDigester != null) {
+                log.trace("Adding CSP digester to context");
+                context.put("cspDigester", cspDigester);
+            }
+            if (cspNonceGenerator != null) {
+                log.trace("Adding CSP nonce generator to context");
+                context.put("cspNonce", cspNonceGenerator);
+            }
+            
+            context.put("action", HTMLEncoder.encodeForHTMLAttribute(decodedTarget));
+            context.put("params", params);
+            
+            final RemotedHttpServletResponse response = agentRequestContext.getRemotedHttpServletResponse();
+            // We know this is non-null since we created the output objects if required.
+            assert response != null;
+            
+            context.put("response", response);
+            
+            HttpServletSupport.addNoCacheHeaders(response);
+            HttpServletSupport.setContentType(response, "text/html");
+            response.setCharacterEncoding(decoder.charset().name());
+            response.setStatus(HttpServletResponse.SC_OK);
+            
+            try (final Writer out = new OutputStreamWriter(response.getOutputStream(), decoder.charset())) {
+                velocityEngine.mergeTemplate(velocityTemplateId, decoder.charset().name(), context, out);
+                out.flush();
+            } catch (final IOException e) {
+                log.error("{} Exception producing form response to client for POST recovery", getLogPrefix(), e);
+                ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            }
+        } finally {
+            RemotedHttpServletRequestResponseContext.clearCurrent();
+        }
+    }
+    
+    /**
+     * Checks for stored data based on the state token and cookie(s) supplied and reads back from
+     * storage, deleting the record.
+     * 
+     * <p>Errors here are logged and suppressed since there's nothing to be done about them.</p>
+     * 
+     * @return recovered data, if any
+     */
+    @Nullable private String getRecoveredData() {
+
+        final AgentRequestContext agentRequestContext = ensureAgentRequestContext();
+        
+        final DDF input = agentRequestContext.getInput();
+        final String stateToken = input != null ? input.getmember(SPConstants.STATE).string() : null;
+        if (stateToken == null) {
+            log.debug("{} No state token found in request, skipping POST recovery check", getLogPrefix());
+            return null;
+        }
+        
+        // Do the needful to allow cookies to be loaded/cleared from wrapped input.
+        try {
+            RemotedHttpServletRequestResponseContext.loadCurrent(agentRequestContext.getRemotedHttpServletRequest(),
+                    agentRequestContext.getRemotedHttpServletResponse());
+         
+            // Check for cookie to get storage key.
+            final String key = cookieManager.getCookieValue(cookiePrefix + stateToken, null);
+            if (key == null) {
+                log.debug("{} No recovery cookie for state token {}, skipping POST recovery check", getLogPrefix(),
+                        stateToken);
+                return null;
+            }
+            
+            // Unset the cookie.
+            cookieManager.unsetCookie(cookiePrefix + stateToken);
+            
+            // Try and read/delete the storage record.
+            
+            final StorageRecord<String> record = storageService.read(ensureAgent().getId() + ".PostData", key);
+            if (record == null) {
+                log.warn("{} POST recovery record was missing for key: {}", getLogPrefix(), key);
+                return null;
+            }
+            
+            try {
+                storageService.delete(ensureAgent().getId() + ".PostData", key);
+            } catch (final IOException e) {
+                log.warn("{} Error deleting POST recovery record for key: {}", getLogPrefix(), key);
+            }
+            
+            return record.getValue();
+            
+        } catch (final IOException e) {
+            log.error("{} Error reading storage record for POST data", getLogPrefix(), e);
+        } finally {
+            RemotedHttpServletRequestResponseContext.clearCurrent();
+        }
+        
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/sp-server-impl/src/main/resources/templates/sp/add-html-body-content.vm b/sp-server-impl/src/main/resources/templates/sp/add-html-body-content.vm
new file mode 100644
index 0000000..bcb4b5a
--- /dev/null
+++ b/sp-server-impl/src/main/resources/templates/sp/add-html-body-content.vm
@@ -0,0 +1,2 @@
+## Stub file that one can replace with Velocity template (and thus HTML) content
+## to be placed into the BODY section of the response
\ No newline at end of file
diff --git a/sp-server-impl/src/main/resources/templates/sp/add-html-head-content.vm b/sp-server-impl/src/main/resources/templates/sp/add-html-head-content.vm
new file mode 100644
index 0000000..08aaeaa
--- /dev/null
+++ b/sp-server-impl/src/main/resources/templates/sp/add-html-head-content.vm
@@ -0,0 +1,2 @@
+## Stub file that one can replace with Velocity template (and thus HTML) content
+## to be placed into the HEAD section of the response
\ No newline at end of file
diff --git a/sp-server-impl/src/main/resources/templates/sp/post-replay.vm b/sp-server-impl/src/main/resources/templates/sp/post-replay.vm
new file mode 100644
index 0000000..f536c47
--- /dev/null
+++ b/sp-server-impl/src/main/resources/templates/sp/post-replay.vm
@@ -0,0 +1,54 @@
+##
+## Velocity Template for SP POST recover form
+##
+## Velocity context may contain the following properties
+## response - HttpServletResponse
+## cspDigester - Calculates base64-encoded SHA-2 hashes (call apply)
+## cspNonce - Calculates secure nonces (call generateIdentifier)
+## action - String - the HTML-encoded action URL for the form
+## params - List<Pair<String,String>> - the HTML-encoded form parameter n-v pairs
+##
+#set ($onLoad="submitOnce()")
+#if ($cspDigester)$response.addHeader("Content-Security-Policy", "script-src-attr 'unsafe-hashes' 'sha256-$cspDigester.apply($onLoad)'")#end
+## Nonce for dynanmic scripts.
+#set ($nonce = $cspNonce.generateIdentifier())
+$response.addHeader("Content-Security-Policy", "script-src-elem 'nonce-$nonce'")
+<!DOCTYPE html>
+<html>
+    <head>
+        #parse ( "/templates/sp/add-html-head-content.vm" )
+        <script #if ($nonce)nonce="$nonce"#end>
+        <!--
+        function submitOnce() {
+          if (location.hash.length>0) {
+             if (confirm("Are you sure you want to resubmit this form information a second time?")) {
+                document.getElementById("shib_continue").click();
+             } else {
+                document.body.innerHTML="<html>Form information was not resubmitted.</html>";
+             }
+          } else {
+             var loc = window.location;
+             window.location = loc + "#submitted";
+             document.getElementById("shib_continue").click();
+          }
+        }
+        // -->
+        </script>
+    </head>
+    <body onload="$onLoad">
+        <h2>Login Completed</h2>
+        <noscript>
+            <p>A form submission to this site was interrupted by the login process.
+            If you would like to complete it now, submit this form.</p>
+        </noscript>
+
+        <form method="POST" action="$action">
+#foreach ($p in $params)
+            <input type="hidden" name="$p.first" value="$p.second"/>
+#end
+            <input type="submit" id="shib_continue" name="_shib_continue_" value="Continue"/>
+        </form>
+        
+        #parse ( "/templates/add-html-body-content.vm" )
+    </body>
+</html>
\ No newline at end of file
diff --git a/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java
index 3ff678c..95c2c1b 100644
--- a/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java
+++ b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/PreservePostDataTest.java
@@ -35,8 +35,6 @@ import org.testng.annotations.Test;
 import jakarta.servlet.http.Cookie;
 import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.codec.Base64Support;
-import net.shibboleth.shared.codec.DecodingException;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.net.CookieManager;
 import net.shibboleth.shared.net.CookieManager.SameSiteValue;
@@ -53,8 +51,6 @@ public class PreservePostDataTest extends BaseAgplicationActionTest {
 
     @Nonnull @NotEmpty private final static String TEST_STATE = "foo";
     @Nonnull @NotEmpty private final static String TEST_DATA = "foo=bar&zorkmid=a+b";
-
-    private String requestId;
     
     private DDF input;
     private MockHttpServletRequest request;
@@ -154,7 +150,7 @@ public class PreservePostDataTest extends BaseAgplicationActionTest {
     }
     
     @Test
-    public void testSuccess() throws IOException, DecodingException {
+    public void testSuccess() throws IOException {
         final Event event = action.execute(src);
         ActionTestingSupport.assertProceedEvent(event);
         Assert.assertEquals(response.getCookies().length, 1);
@@ -166,14 +162,14 @@ public class PreservePostDataTest extends BaseAgplicationActionTest {
         
         final String key = cookie.getValue();
         
-        StorageRecord<String> record = storageService.read(agent.getId() + ".PostData", key);
+        final StorageRecord<String> record = storageService.read(agent.getId() + ".PostData", key);
         assert record != null;
         Assert.assertEquals(record.getVersion(), 1);
-        Assert.assertEquals(Base64Support.decode(record.getValue()), TEST_DATA.getBytes());
+        Assert.assertEquals(record.getValue(), TEST_DATA);
     }
         
     @Test
-    public void testPurge() throws ComponentInitializationException, DecodingException, InterruptedException {
+    public void testPurge() throws ComponentInitializationException, InterruptedException {
         
         final List<Cookie> cookies = new ArrayList<>(12);
         for (int i = 0; i < 12; ++i) {
diff --git a/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/RecoverPostDataTest.java b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/RecoverPostDataTest.java
new file mode 100644
index 0000000..cd42f57
--- /dev/null
+++ b/sp-server-impl/src/test/java/net/shibboleth/sp/profile/impl/RecoverPostDataTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.profile.impl;
+
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.runtime.RuntimeConstants;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.Event;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import jakarta.servlet.http.Cookie;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.codec.StringDigester;
+import net.shibboleth.shared.codec.StringDigester.OutputFormat;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.net.CookieManager;
+import net.shibboleth.shared.net.CookieManager.SameSiteValue;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy.ProviderType;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+import net.shibboleth.sp.messaging.RemotedHttpServletResponse;
+import net.shibboleth.sp.profile.SPConstants;
+
+/**
+ * Unit test for {@link RecoverPostData} action.
+ */
+ at SuppressWarnings("javadoc")
+public class RecoverPostDataTest extends BaseAgplicationActionTest {
+
+    @Nonnull @NotEmpty private final static String TEST_STATE = "foo";
+    @Nonnull @NotEmpty private final static String TEST_DATA = "foo=bar&zorkmid=a+b";
+    @Nonnull @NotEmpty private final static String BAD_TEST_DATA = "foo=bar&zorkmid=a+b&bad=%EB%8C%04";
+    
+    private DDF input;
+    private MockHttpServletRequest request;
+    private MockHttpServletResponse response;
+    
+    private MemoryStorageService storageService;
+    private CookieManager cookieManager;
+    private VelocityEngine velocityEngine;
+    
+    private RecoverPostData action;
+    
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     * @throws NoSuchAlgorithmException 
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException, NoSuchAlgorithmException {
+        super.beforeMethod();
+                
+        request = new MockHttpServletRequest();
+        response = new MockHttpServletResponse();
+        
+        cookieManager = new CookieManager();
+        cookieManager.setHttpServletRequestSupplier(NonnullSupplier.of(request));
+        cookieManager.setHttpServletResponseSupplier(NonnullSupplier.of(response));
+        cookieManager.setCookieLimit(10);
+        cookieManager.setSameSite(SameSiteValue.None);
+        cookieManager.setMaxAge(-1);
+        cookieManager.initialize();
+        
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.setCleanupInterval(Duration.ZERO);
+        storageService.initialize();
+                
+        velocityEngine = new VelocityEngine();
+        velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADERS, "classpath");
+        velocityEngine.setProperty("classpath.resource.loader.class",
+                "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
+        velocityEngine.init();
+        
+        action = new RecoverPostData();
+        action.setCookieManager(cookieManager);
+        action.setStorageService(storageService);
+        action.setVelocityEngine(velocityEngine);
+        action.setCSPDigester(new StringDigester("SHA256", OutputFormat.BASE64));
+        action.setCSPNonceGenerator(IdentifierGenerationStrategy.getInstance(ProviderType.SECURE));
+        action.initialize();
+
+        input = new DDF(null).structure();
+        input.addmember(SPConstants.STATE).string(TEST_STATE);
+        input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME);
+        
+        arc.setInput(input);
+    }
+    
+    /**
+     * Tear down test.
+     */
+    @AfterMethod
+    public void tearDown() {
+        action.destroy();
+        cookieManager.destroy();
+        storageService.destroy();
+    }
+        
+    @Test
+    public void testNoData() {
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        validateNoResponse(0);
+    }
+
+    @Test
+    public void testNoStateToken() {
+        input.addmember(SPConstants.STATE).remove();
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        validateNoResponse(0);
+    }
+    
+    @Test
+    public void testDisallowed() throws IOException {
+        agent.setSupportsPostPreservation(false);
+        storePOSTData(TEST_DATA);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        validateNoResponse(1);
+    }
+
+    @Test
+    public void testNoTargetURL() throws IOException {
+        storePOSTData(TEST_DATA);
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        validateNoResponse(1);
+    }
+
+    @Test
+    public void testBadTargetEncoding() throws IOException {
+        storePOSTData(TEST_DATA);
+        
+        // Simple invalid UTF-8 sequence (cribbed from a web site).
+        final byte[] invalid = { (byte)235, (byte)140, (byte)4 };
+        
+        arc.setTargetURL(invalid);
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        validateNoResponse(1);
+    }
+
+    @Test
+    public void testSuccess() throws IOException {
+        
+        final String key = storePOSTData(TEST_DATA);
+        arc.setTargetURL("https://sp.example.org/test.cgi".getBytes());
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        
+        Assert.assertEquals(response.getCookies().length, 1);
+        
+        final RemotedHttpServletResponse remotedResponse = arc.getRemotedHttpServletResponse();
+        assert remotedResponse != null;
+        Assert.assertTrue(remotedResponse.isCommitted());
+        
+        Assert.assertEquals(response.getCookies().length, 1);
+        Assert.assertNull(storageService.read(agent.getId() + ".PostData", key));
+
+        final DDF output = arc.getOutput();
+        assert output != null;
+        final DDF response = output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME)
+        .getmember(RemotedHttpServletResponse.RESPONSE);
+        Assert.assertEquals(response.getmember(RemotedHttpServletResponse.STATUS).integer(), 200);
+        Assert.assertNotNull(response.getmember(RemotedHttpServletResponse.DATA).unsafe_string());
+    }
+
+    /**
+     * Validate the action doing no work.
+     */
+    private void validateNoResponse(final int cookieHeaderCount) {
+        final RemotedHttpServletResponse remotedResponse = arc.getRemotedHttpServletResponse();
+        assert remotedResponse != null;
+        Assert.assertFalse(remotedResponse.isCommitted());
+        
+        final DDF output = arc.getOutput();
+        assert output != null;
+        Assert.assertNull(
+                output.getmember(RemotedHttpServletResponse.STRUCTURE_NAME)
+                .getmember(RemotedHttpServletResponse.RESPONSE)
+                .getmember(RemotedHttpServletResponse.DATA).unsafe_string()
+                );
+        
+        Assert.assertEquals(response.getCookies().length, cookieHeaderCount);
+    }
+    
+    /**
+     * Insert a storage record for the POST data to recover.
+     * 
+     * @param data data to recover
+     * 
+     * @throws IOException
+     */
+    @Nonnull private String storePOSTData(@Nonnull final String data) throws IOException {
+        final String key = IdentifierGenerationStrategy.getInstance(ProviderType.SECURE).generateIdentifier(false);
+        Assert.assertTrue(
+                storageService.create(agent.ensureId() + ".PostData", key, data,
+                        Instant.now().plus(Duration.ofMinutes(15)).toEpochMilli()));
+        final Cookie cookie = new Cookie(RecoverPostData.DEFAULT_COOKIE_PREFIX + TEST_STATE, key);
+        request.setCookies(cookie);
+        return key;
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-server-impl/src/test/resources/logback-test.xml b/sp-server-impl/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..ec68238
--- /dev/null
+++ b/sp-server-impl/src/test/resources/logback-test.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<configuration>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%level [%logger:%line] - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <root>
+        <level value="INFO" />
+        <appender-ref ref="STDOUT" />
+    </root>
+    
+</configuration>
\ 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