[java-plugin-shibd-saml] branch main updated: ECP options parsing and tracking.

Codeberg noreply at shibboleth.net
Wed Aug 5 15:54:17 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/b00d42c447dba577f3e013d2610e939688f3e17e

The following commit(s) were added to refs/heads/main by this push:
     new b00d42c  ECP options parsing and tracking.
b00d42c is described below

commit b00d42c447dba577f3e013d2610e939688f3e17e
Author: Scott Cantor <scott at restingparrotsoftware.com>
AuthorDate: Wed Aug 5 11:54:03 2026 -0400

    ECP options parsing and tracking.
---
 .../sp/saml/saml2/context/ECPOptionsContext.java   | 123 +++++++++++++++
 .../saml2/profile/SAML2InitiatorConstants.java     |  23 +++
 .../impl/ECPHttpServletRequestValidator.java       |  10 +-
 .../saml/saml2/profile/impl/ParseECPOptions.java   | 147 ++++++++++++++++++
 .../saml2/profile/impl/ParseECPOptionsTest.java    | 168 +++++++++++++++++++++
 5 files changed, 465 insertions(+), 6 deletions(-)

diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/ECPOptionsContext.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/ECPOptionsContext.java
new file mode 100644
index 0000000..7bb400a
--- /dev/null
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/context/ECPOptionsContext.java
@@ -0,0 +1,123 @@
+/*
+ * 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;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.BaseContext;
+
+/**
+ * Tracks SAML ECP profile options.
+ */
+public class ECPOptionsContext extends BaseContext {
+
+    /** Indicates the client wants the SP to sign the reqyuest. */
+    private boolean wantAuthnRequestSigned;
+    
+    /** Indicates the client supports channel bindings. */
+    private boolean channelBinding;
+    
+    /** Indicates the client supports holder of key confirmation. */
+    private boolean holderOfKey;
+    
+    /** Indicates the client supports delegation. */
+    private boolean delegation;
+    
+    /**
+     * Checks for the existence of the WantAuthnRequestsSigned option in the client's request.
+     * 
+     * @return true iff the option was set
+     */
+    public boolean isWantAuthnRequestsSigned() {
+        return wantAuthnRequestSigned;
+    }
+    
+    /**
+     * Sets the status of the WantAuthnRequestsSigned option in the client's request.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public ECPOptionsContext setWantAuthnRequestsSigned(final boolean flag) {
+        wantAuthnRequestSigned = flag;
+        return this;
+    }
+    
+    /**
+     * Checks for the existance of the channel-binding option in the client's request, indicating
+     * support for the feature.
+     * 
+     * @return true iff the option was set
+     */
+    public boolean isChannelBinding() {
+        return channelBinding;
+    }
+    
+    /**
+     * Sets the status of the channel-binding option in the client's request.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public ECPOptionsContext setChannelBinding(final boolean flag) {
+        channelBinding = flag;
+        return this;
+    }
+    
+    /**
+     * Checks for the existance of the holder-of-key option in the client's request.
+     * 
+     * @return true iff the option was set
+     */
+    public boolean isHolderOfKey() {
+        return holderOfKey;
+    }
+    
+    /**
+     * Sets the status of the holder-of-key option in the client's request.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public ECPOptionsContext setHolderOfKey(final boolean flag) {
+        holderOfKey = flag;
+        return this;
+    }
+    
+    /**
+     * Checks for the existence of the delegation option in the client's request.
+     * 
+     * @return true iff the option was set
+     */
+    public boolean isDelegation() {
+        return delegation;
+    }
+    
+    /**
+     * Sets the status of the delegation option in the client's request.
+     * 
+     * @param flag flag to set
+     * 
+     * @return this context
+     */
+    @Nonnull public ECPOptionsContext setDelegation(final boolean flag) {
+        delegation = flag;
+        return this;
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/SAML2InitiatorConstants.java b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/SAML2InitiatorConstants.java
index 3d2ac82..456d781 100644
--- a/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/SAML2InitiatorConstants.java
+++ b/sp-saml-api/src/main/java/net/shibboleth/sp/saml/saml2/profile/SAML2InitiatorConstants.java
@@ -16,8 +16,10 @@ package net.shibboleth.sp.saml.saml2.profile;
 
 import javax.annotation.Nonnull;
 
+import org.opensaml.saml.common.xml.SAMLConstants;
 import org.opensaml.saml.saml2.core.AuthnContextClassRef;
 import org.opensaml.saml.saml2.core.NameIDPolicy;
+import org.opensaml.saml.saml2.core.SubjectConfirmation;
 import org.opensaml.saml.saml2.metadata.NameIDFormat;
 
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
@@ -26,6 +28,8 @@ import net.shibboleth.shared.annotation.constraint.NotEmpty;
  * Constants for SAML 2.0 session initiator operations.
  * 
  * <p>Most are named for compatibility with settings used in older SP versions.</p>
+ * 
+ * TODO: Some of these should ve moved over to OpenSAML's SAMLConstants class.
  */
 public final class SAML2InitiatorConstants {
 
@@ -50,6 +54,25 @@ public final class SAML2InitiatorConstants {
     /** SPNameQualifier input parameter. */
     @Nonnull @NotEmpty public static final String SP_NAME_QUALIFIER = NameIDPolicy.SP_NAME_QUALIFIER_ATTRIB_NAME;
 
+    /** PAOS media type for ECP client requests. */
+    @Nonnull @NotEmpty public static final String PAOS_MEDIA_TYPE = "application/vnd.paos+xml";
+    
+    /** Required start of PAOS header during ECP client requests. */
+    @Nonnull @NotEmpty public static final String PAOS_HEADER_VERSION_INDICATOR = "ver=\"urn:liberty:paos:2003-08\";";
+    
+    /** ECP option flag for request signing. */
+    @Nonnull @NotEmpty public static final String ECP20_OPTION_WANT_AUTHN_REQUESTS_SIGNED =
+            "urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp:2.0:WantAuthnRequestsSigned";
+
+    /** ECP option flag for channel binding. */
+    @Nonnull @NotEmpty public static final String ECP20_OPTION_CHANNEL_BINDING = SAMLConstants.SAML20CB_NS;
+
+    /** ECP option flag for holder of key. */
+    @Nonnull @NotEmpty public static final String ECP20_OPTION_HOLDER_OF_KEY = SubjectConfirmation.METHOD_HOLDER_OF_KEY;
+    
+    /** ECP option flag for delegation. */
+    @Nonnull @NotEmpty public static final String ECP20_OPTION_DELEGATION = SAMLConstants.SAML20DEL_NS;
+
     /** Private constructor. */
     private SAML2InitiatorConstants() {
      
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/binding/impl/ECPHttpServletRequestValidator.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/binding/impl/ECPHttpServletRequestValidator.java
index f5972b4..b528962 100644
--- a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/binding/impl/ECPHttpServletRequestValidator.java
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/binding/impl/ECPHttpServletRequestValidator.java
@@ -25,6 +25,7 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import net.shibboleth.shared.primitive.StringSupport;
 import net.shibboleth.shared.servlet.HttpServletRequestValidator;
+import net.shibboleth.sp.saml.saml2.profile.SAML2InitiatorConstants;
 
 /**
  * A request validator that detects the requirements of an ECP client request:
@@ -40,9 +41,6 @@ import net.shibboleth.shared.servlet.HttpServletRequestValidator;
  * </ol>
  */
 public class ECPHttpServletRequestValidator implements HttpServletRequestValidator {
-
-    /** Required start of PAOS header. */
-    @Nonnull private static final String PAOS_VERSION = "ver=\"urn:liberty:paos:2003-08\";";
     
     /** Media type for PAOS. */
     @Nonnull private final MediaType paosType;
@@ -50,7 +48,7 @@ public class ECPHttpServletRequestValidator implements HttpServletRequestValidat
     /** Constructor. */
     @SuppressWarnings("null")
     public ECPHttpServletRequestValidator() {
-        paosType = MediaType.parseMediaType("application/vnd.paos+xml");
+        paosType = MediaType.parseMediaType(SAML2InitiatorConstants.PAOS_MEDIA_TYPE);
     }
     
     /** {@inheritDoc} */
@@ -76,11 +74,11 @@ public class ECPHttpServletRequestValidator implements HttpServletRequestValidat
             throw new ServletException("No PAOS header found in request.");
         }
         
-        if (!paosHeader.startsWith(PAOS_VERSION)) {
+        if (!paosHeader.startsWith(SAML2InitiatorConstants.PAOS_HEADER_VERSION_INDICATOR)) {
             throw new ServletException("PAOS header does not indicate expected version support.");
         }
         
-        final String paosService = paosHeader.substring(PAOS_VERSION.length());
+        final String paosService = paosHeader.substring(SAML2InitiatorConstants.PAOS_HEADER_VERSION_INDICATOR.length());
         if (paosService != null) {
             final String[] options = paosService.split(",");
             if (options != null && options.length > 0 &&
diff --git a/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptions.java b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptions.java
new file mode 100644
index 0000000..ce871ec
--- /dev/null
+++ b/sp-saml-impl/src/main/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptions.java
@@ -0,0 +1,147 @@
+/*
+ * 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.util.function.Function;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.sp.context.AgentRequestContext;
+import net.shibboleth.sp.profile.AbstractApplicationAction;
+import net.shibboleth.sp.saml.saml2.context.ECPOptionsContext;
+import net.shibboleth.sp.saml.saml2.profile.SAML2InitiatorConstants;
+
+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.slf4j.Logger;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * Action that parses a client's "PAOS" header to populate an {@link ECPOptionsContext} with its
+ * flags.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_MESSAGE}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * 
+ * @post ProfileRequestContext.getSubcontext(ECPOptionsContext.class) != null
+ */
+public class ParseECPOptions extends AbstractApplicationAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(AddLogoutRequest.class);
+    
+    /** Strategy used to create the {@link ECPOptionsContext} to populate. */
+    @Nonnull private Function<ProfileRequestContext,ECPOptionsContext> ecpOptionsContextCreationStrategy;
+    
+    /** Split options array. */
+    @NonnullBeforeExec private String[] options;
+    
+    /** Constructor. */
+    public ParseECPOptions() {
+        // Default strategy is a 16-byte secure random source.
+        ecpOptionsContextCreationStrategy = new ChildContextLookup<>(ECPOptionsContext.class, true);
+    }
+        
+    /**
+     * Sets the strategy used to create the {@link ECPOptionsContext}.
+     * 
+     * @param strategy creation strategy
+     */
+    public void setStateDataContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,ECPOptionsContext> strategy) {
+        checkSetterPreconditions();
+        ecpOptionsContextCreationStrategy =
+                Constraint.isNotNull(strategy, "ECPOptionsContext creation strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        final AgentRequestContext agentRequestContext =
+                profileRequestContext.ensureSubcontext(AgentRequestContext.class);
+        final HttpServletRequest request = agentRequestContext.getRemotedHttpServletRequest();
+        
+        final String paosHeader = request != null ? request.getHeader("PAOS") : null;
+        if (paosHeader == null || !paosHeader.startsWith(SAML2InitiatorConstants.PAOS_HEADER_VERSION_INDICATOR)) {
+            log.error("{} Remoted client request did not contain valid PAOS header: {}", getLogPrefix(), paosHeader);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE);
+            return false;
+        }
+        
+        final String paosService = paosHeader.substring(SAML2InitiatorConstants.PAOS_HEADER_VERSION_INDICATOR.length());
+        options = paosService != null ? paosService.split(",") : null;
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final ECPOptionsContext optionsContext = ecpOptionsContextCreationStrategy.apply(profileRequestContext);
+        if (optionsContext == null) {
+            log.error("{} Error creating ECPOptionsContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        
+        if (options == null || options.length <= 1) {
+            log.debug("{} No ECP options present", getLogPrefix());
+            return;
+        }
+        
+        for (int i = 1; i < options.length; ++i) {
+            final String opt = StringSupport.trimOrNull(options[i]);
+            if (opt == null) {
+                continue;
+            }
+            
+            switch (opt) {
+                case SAML2InitiatorConstants.ECP20_OPTION_WANT_AUTHN_REQUESTS_SIGNED:
+                    optionsContext.setWantAuthnRequestsSigned(true);
+                    break;
+                    
+                case SAML2InitiatorConstants.ECP20_OPTION_CHANNEL_BINDING:
+                    optionsContext.setChannelBinding(true);
+                    break;
+                    
+                case SAML2InitiatorConstants.ECP20_OPTION_HOLDER_OF_KEY:
+                    optionsContext.setHolderOfKey(true);
+                    break;
+
+                case SAML2InitiatorConstants.ECP20_OPTION_DELEGATION:
+                    optionsContext.setDelegation(true);
+                    break;
+            }
+        }
+        
+        log.debug("{} ECP options: WantAuthnRequestsSigned={}, channel-binding={}, holder-of-key={}, delegation={}",
+                getLogPrefix(), optionsContext.isWantAuthnRequestsSigned(), optionsContext.isChannelBinding(),
+                optionsContext.isHolderOfKey(), optionsContext.isDelegation());
+    }
+    
+}
\ No newline at end of file
diff --git a/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptionsTest.java b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptionsTest.java
new file mode 100644
index 0000000..7c5af22
--- /dev/null
+++ b/sp-saml-impl/src/test/java/net/shibboleth/sp/saml/saml2/profile/impl/ParseECPOptionsTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.saml.common.xml.SAMLConstants;
+import org.springframework.webflow.execution.Event;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.sp.ddf.DDF;
+import net.shibboleth.sp.messaging.RemotedHttpServletRequest;
+import net.shibboleth.sp.profile.impl.BaseApplicationActionTest;
+import net.shibboleth.sp.saml.saml2.context.ECPOptionsContext;
+import net.shibboleth.sp.saml.saml2.profile.SAML2InitiatorConstants;
+
+/**
+ * Unit test for {@link ParseECPOptions} action.
+ */
+ at SuppressWarnings("javadoc")
+public class ParseECPOptionsTest extends BaseApplicationActionTest {
+
+    private DDF httpRequest;
+    private ParseECPOptions action;
+    
+    /**
+     * Set up test.
+     * 
+     * @throws ComponentInitializationException
+     */
+    @BeforeMethod
+    public void setUp() throws ComponentInitializationException {
+        super.beforeMethod();
+        
+        action = new ParseECPOptions();
+        action.initialize();
+
+        final DDF input = new DDF(null).structure();
+        arc.setInput(input);
+        
+        httpRequest = input.addmember(RemotedHttpServletRequest.STRUCTURE_NAME).structure();
+        httpRequest.addmember(RemotedHttpServletRequest.HEADERS).structure();
+        
+        arc.setRemotedHttpServletRequest(new RemotedHttpServletRequest(httpRequest));
+    }
+    
+    @Test
+    public void testNoInput() {
+        arc.setRemotedHttpServletRequest(null);
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MESSAGE);
+    }
+    
+    @Test
+    public void testNoPAOSHeader() {
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MESSAGE);
+    }
+    
+    @Test
+    public void testWrongPAOSPrefix() {
+        addPAOSHeader("foo");
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_MESSAGE);
+    }
+    
+    @Test(dataProvider="flags")
+    public void testOptions(final boolean signed, final boolean cb, final boolean hok, final boolean del) {
+        final StringBuilder builder = new StringBuilder(SAML2InitiatorConstants.PAOS_HEADER_VERSION_INDICATOR + ' ' + SAMLConstants.SAML20ECP_NS);
+        if (signed) {
+            builder.append(", ").append(SAML2InitiatorConstants.ECP20_OPTION_WANT_AUTHN_REQUESTS_SIGNED);
+        }
+        if (cb) {
+            builder.append(",").append(SAML2InitiatorConstants.ECP20_OPTION_CHANNEL_BINDING);
+        }
+        if (hok) {
+            builder.append(",  ").append(SAML2InitiatorConstants.ECP20_OPTION_HOLDER_OF_KEY);
+        }
+        if (del) {
+            builder.append(",").append(SAML2InitiatorConstants.ECP20_OPTION_DELEGATION);
+        }
+        
+        addPAOSHeader(builder.toString());
+        
+        final Event event = action.execute(src);
+        ActionTestingSupport.assertProceedEvent(event);
+        
+        validateOptions(signed, cb, hok, del);
+    }
+    
+    @DataProvider(name = "flags")
+    public Object[][] getFlags() throws Exception {
+        return new Object[][] {
+            new Object[] {
+                    false, false, false, false
+            },
+            new Object[] {
+                    true, false, false, false
+            },
+            new Object[] {
+                    false, true, false, false
+            },
+            new Object[] {
+                    false, false, true, false
+            },
+            new Object[] {
+                    false, false, false, true
+            },
+            new Object[] {
+                    true, false, false, true
+            },
+            new Object[] {
+                    false, true, true, false
+            },
+        };
+    }
+    
+    /**
+     * Add a PAOS header.
+     * 
+     * @param value header value
+     */
+    private void addPAOSHeader(@Nonnull final String value) {
+        httpRequest.getmember(RemotedHttpServletRequest.HEADERS).addmember("PAOS").unsafe_string(value.getBytes(StandardCharsets.UTF_8));
+    }
+    
+    /**
+     * Validate context exists with expected options.
+     * 
+     * @param signed signed flag
+     * @param cb channel binding flag
+     * @param hok holder of key flag
+     * @param del delegation flag
+     */
+    private void validateOptions(final boolean signed, final boolean cb, final boolean hok, final boolean del) {
+        final ECPOptionsContext context = prc.getSubcontext(ECPOptionsContext.class);
+        assert context != null;
+        
+        Assert.assertEquals(context.isWantAuthnRequestsSigned(), signed);
+        Assert.assertEquals(context.isChannelBinding(), cb);
+        Assert.assertEquals(context.isHolderOfKey(), hok);
+        Assert.assertEquals(context.isDelegation(), del);
+    }
+    
+}
\ 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