[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