[java-idp-plugin-vci] 05/05: DPoP proof validation per rfc9449#section-4.3: 'ath' required at the credential endpoint, 'htu' matched after normalization ignoring query and fragment

Codeberg noreply at shibboleth.net
Mon Sep 28 15:53:46 UTC 2026


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

codeberg pushed a commit to branch dev/FAPI
in repository java-idp-plugin-vci.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/7c11d50c1f6178b952d516591f712169ba883796

commit 7c11d50c1f6178b952d516591f712169ba883796
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Mon Sep 28 18:53:26 2026 +0300

    DPoP proof validation per rfc9449#section-4.3: 'ath' required at the credential endpoint, 'htu' matched after normalization ignoring query and fragment
---
 .../security/impl/DPoPHttpUriClaimsValidator.java  | 122 +++++++++++++++++++++
 .../idp/service/relying-party/postconfig.xml       |  64 ++++++++++-
 .../impl/DPoPHttpUriClaimsValidatorTest.java       |  97 ++++++++++++++++
 3 files changed, 281 insertions(+), 2 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidator.java
new file mode 100644
index 0000000..f4a9188
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidator.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.security.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+import java.util.function.BiFunction;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.AbstractClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Validator of the 'htu' claim of a DPoP proof, matched against the request URI after RFC 3986 syntax and scheme
+ * based normalization, ignoring the query and fragment parts.
+ */
+public class DPoPHttpUriClaimsValidator extends AbstractClaimsValidator {
+
+    /** Claim name of the HTTP target URI. */
+    @Nonnull
+    public static final String HTU = "htu";
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(DPoPHttpUriClaimsValidator.class);
+
+    /** Strategy used to locate the request URI. */
+    @NonnullAfterInit
+    private BiFunction<ProfileRequestContext, JWTClaimsSet, String> requestUriLookupStrategy;
+
+    /**
+     * Set the strategy used to locate the request URI.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setRequestUriLookupStrategy(
+            @Nonnull final BiFunction<ProfileRequestContext, JWTClaimsSet, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        requestUriLookupStrategy = Constraint.isNotNull(strategy, "Request URI lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (requestUriLookupStrategy == null) {
+            throw new ComponentInitializationException("Request URI lookup strategy cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doValidate(@Nonnull final JWTClaimsSet claims, @Nonnull final ProfileRequestContext context)
+            throws JWTValidationException {
+        final Object claim = claims.getClaim(HTU);
+        if (!(claim instanceof String htu)) {
+            throw new JWTValidationException("Claim '" + HTU + "' does not exist or is not a string");
+        }
+        final String expected = requestUriLookupStrategy.apply(context, claims);
+        if (expected == null) {
+            throw new JWTValidationException("No request URI to match claim '" + HTU + "' against");
+        }
+        if (!normalize(htu).equals(normalize(expected))) {
+            throw new JWTValidationException("JWT \"" + HTU + "\" claim has value " + htu + " but should be "
+                    + expected);
+        }
+        log.trace("{}: Claim '{}' {} matches {}", getId(), HTU, htu, expected);
+    }
+
+    /**
+     * Normalize a URI as RFC 3986 6.2.2 and 6.2.3, without query and fragment.
+     *
+     * @param uri URI
+     * @return normalized URI
+     * @throws JWTValidationException if the URI cannot be parsed
+     */
+    @Nonnull
+    private static String normalize(@Nonnull final String uri) throws JWTValidationException {
+        final URI parsed;
+        try {
+            parsed = new URI(uri);
+        } catch (final URISyntaxException e) {
+            throw new JWTValidationException("Invalid URI " + uri);
+        }
+        if (parsed.getScheme() == null || parsed.getHost() == null) {
+            throw new JWTValidationException("Invalid URI " + uri);
+        }
+        final String scheme = parsed.getScheme().toLowerCase(Locale.ROOT);
+        final int port = parsed.getPort();
+        final boolean defaultPort = port == -1 || "https".equals(scheme) && port == 443
+                || "http".equals(scheme) && port == 80;
+        final String path = parsed.getRawPath() == null || parsed.getRawPath().isEmpty() ? "/" : parsed.getRawPath();
+        return scheme + "://" + (parsed.getRawUserInfo() != null ? parsed.getRawUserInfo() + "@" : "")
+                + parsed.getHost().toLowerCase(Locale.ROOT) + (defaultPort ? "" : ":" + port) + path;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 70d07d6..58a39db 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -28,7 +28,7 @@
         p:tokenEndpointAuthMethod="%{idp.authn.oidc.rp.client.authenticationMethod:client_secret_basic}"
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
         p:claimsValidator="#{getObject('DefaultJWTClaimsValidator')}"
-        p:dpopProofClaimsValidator="#{getObject('DefaultDPoPProofClaimsValidator')}"
+        p:dpopProofClaimsValidator="#{getObject('openidvci.DefaultDPoPProofClaimsValidator')}"
         p:dpopProofSignatureValidationConfiguration="#{getObject('DPoPSignatureValidationConfiguration')}"
         p:dpopProofNonceGenerator="#{getObject('DefaultOAuth2DPoPNonceGenerator')}"
         p:proofSignatureValidationConfiguration="#{getObject('openidvci.ProofSignatureValidationConfiguration')}"
@@ -49,7 +49,8 @@
         
     <bean id="OpenID.VCI.Credentials" parent="AbstractVCIProfile" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialsConfiguration"
-        p:issuedClaimsValidator="#{getObject('openidvci.DefaultAccessTokenClaimsValidator')}" />
+        p:issuedClaimsValidator="#{getObject('openidvci.DefaultAccessTokenClaimsValidator')}"
+        p:dpopProofClaimsValidator="#{getObject('openidvci.DefaultDPoPWithAthProofClaimsValidator')}" />
 
     <!--
     Endpoint publishing Credential Issuer metadata. Serves wallets before there is a
@@ -110,6 +111,65 @@
         class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator"
         p:claimValidators-ref="openidvci.ProofBodyClaimsValidator" />
 
+    <bean id="openidvci.DefaultDPoPProofClaimsValidator"
+        class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator"
+        p:claimValidators-ref="openidvci.DPoPProofClaimsValidators" />
+
+    <bean id="openidvci.DefaultDPoPWithAthProofClaimsValidator"
+        class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator"
+        p:claimValidators-ref="openidvci.DPoPProofWithAthClaimsValidators" />
+
+    <util:list id="openidvci.DPoPProofClaimsValidators" value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
+        <ref bean="ExpiryClaimsValidator" />
+        <ref bean="NotBeforeClaimsValidator" />
+        <bean class="net.shibboleth.oidc.security.jwt.claims.impl.IssuedAtClaimsValidator"
+            p:clockSkew="%{idp.policy.clockSkew:PT1M}"
+            p:messageLifetime="%{idp.policy.messageLifetime:PT1M}"
+            p:requiredRule="true" />
+        <bean class="net.shibboleth.oidc.security.jwt.claims.impl.JWTIdentifierClaimsValidator"
+            p:clockSkew="%{idp.policy.clockSkew:PT1M}"
+            p:replayCache-ref="shibboleth.ReplayCache"
+            p:replayCacheRecordLifetime="%{idp.oauth2.dpop.replayCacheLifetime:PT5M}"
+            p:maxReplayCacheRecordLifetime="%{idp.oauth2.dpop.maxReplayCacheLifetime:PT5M}">
+            <property name="replayCacheKeyCalculationStrategy">
+                <bean class="net.shibboleth.oidc.security.jwt.claims.impl.CalculateSha256DigestForLongKeyFunction"/>
+            </property>
+        </bean>
+        <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator" p:claimName="htm">
+            <property name="valueToMatchLookupStrategy">
+                <bean parent="shibboleth.BiFunctions.Expression" c:expression="#custom.get().getMethod()"
+                    p:customObject-ref="shibboleth.HttpServletRequestSupplier"/>
+            </property>
+        </bean>
+        <bean class="org.geant.shibboleth.plugin.openidvci.security.impl.DPoPHttpUriClaimsValidator">
+            <property name="requestUriLookupStrategy">
+                <bean parent="shibboleth.BiFunctions.Expression" c:expression="#custom.get().getRequestURL().toString()"
+                    p:customObject-ref="shibboleth.HttpServletRequestSupplier"/>
+            </property>
+        </bean>
+        <bean class="net.shibboleth.idp.plugin.oidc.op.security.jwt.claims.impl.DPoPProofNonceClaimsValidator"
+            p:objectMapper-ref="shibboleth.oidc.JSONObjectMapper"
+            c:sealer-ref="DefaultDPoPNonceSealer" />
+    </util:list>
+
+    <bean id="openidvci.DPoPProofWithAthClaimsValidators" parent="openidvci.DPoPProofClaimsValidators"
+        class="org.springframework.beans.factory.config.ListFactoryBean">
+        <property name="sourceList">
+            <list merge="true">
+                <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator" p:claimName="ath">
+                    <property name="valueToMatchLookupStrategy">
+                        <bean parent="shibboleth.BiFunctions.Expression" c:expression="#custom.apply(null)">
+                            <property name="customObject">
+                                <bean class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DPoPAccessTokenHashFromRequestLookupFunction"
+                                    p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"/>
+                            </property>
+                        </bean>
+                    </property>
+                </bean>
+            </list>
+        </property>
+    </bean>
+
     <bean id="openidvci.DefaultAccessTokenClaimsValidator"
         class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator"
         p:claimValidators-ref="openidvci.AccessTokenClaimsValidators" />
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidatorTest.java
new file mode 100644
index 0000000..7338b02
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DPoPHttpUriClaimsValidatorTest.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * 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 org.geant.shibboleth.plugin.openidvci.security.impl;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+
+/**
+ * Unit tests for {@link DPoPHttpUriClaimsValidator}.
+ */
+public class DPoPHttpUriClaimsValidatorTest {
+
+    private static final String REQUEST_URL = "https://issuer.example.org/idp/profile/openid/vci/credentials";
+
+    private DPoPHttpUriClaimsValidator validator;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        validator = new DPoPHttpUriClaimsValidator();
+        validator.setId("htu");
+        validator.setRequestUriLookupStrategy((prc, claims) -> REQUEST_URL);
+        validator.initialize();
+    }
+
+    private void validate(final String htu) throws JWTValidationException {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
+        if (htu != null) {
+            builder.claim(DPoPHttpUriClaimsValidator.HTU, htu);
+        }
+        validator.validate(builder.build(), new ProfileRequestContext());
+    }
+
+    @Test
+    public void testExact() throws Exception {
+        validate(REQUEST_URL);
+    }
+
+    @Test
+    public void testSchemeAndHostCase() throws Exception {
+        validate("HTTPS://Issuer.Example.ORG/idp/profile/openid/vci/credentials");
+    }
+
+    @Test
+    public void testDefaultPort() throws Exception {
+        validate("https://issuer.example.org:443/idp/profile/openid/vci/credentials");
+    }
+
+    @Test
+    public void testQueryAndFragment() throws Exception {
+        validate(REQUEST_URL + "?a=b#c");
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testOtherPort() throws Exception {
+        validate("https://issuer.example.org:8443/idp/profile/openid/vci/credentials");
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testPathCase() throws Exception {
+        validate("https://issuer.example.org/IDP/profile/openid/vci/credentials");
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testOtherPath() throws Exception {
+        validate("https://issuer.example.org/idp/profile/openid/vci/token");
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testMissing() throws Exception {
+        validate(null);
+    }
+
+    @Test(expectedExceptions = JWTValidationException.class)
+    public void testNotUri() throws Exception {
+        validate("not a uri");
+    }
+
+}

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


More information about the commits mailing list