[java-idp-oidc] 01/02: JOIDC-201 - Support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)

Henri Mikkonen henri.mikkonen at iki.fi
Mon May 13 11:49:40 UTC 2024


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

hjmikkon pushed a commit to branch main
in repository java-idp-oidc.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-oidc.git;a=commit;h=b2c844652cba725382293ad0f65129a44c03380c

commit b2c844652cba725382293ad0f65129a44c03380c
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Mon May 13 14:47:36 2024 +0300

    JOIDC-201 - Support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)
    
    https://shibboleth.atlassian.net/browse/JOIDC-201
    
    Initial, still very incomplete commit. WIP.
    
    - DPoP Proof JWK thumbprint fields for OIDCAuthenticationResponseContext and TokenClaimsSet
      - OIDCAuthenticationResponseContext.setAccessToken(..) builds DPoPAccessToken if thumbprint is set
    - PAR/authorize -flows support setting the thumbprint via dpop_jkt -parameter
      - StoreDPoPProofKeyThumbprint action
      - Requirement for the parameter may be set via profile config, defaults to false
    - New OAuth2DPoPProofContext to carry information about DPoP Proof JWT
    - New oauth2/dpop-proof-validation flow to perform DPoP Proof JWT validation
      - Populates security parameters context under inboud / OAuth2DPoPProofContext
      - Signature validation paramters allow validation solely against token-derived credentials
        - configuration via profile configuration settings
      - Currently called by oidc/abstract-api flow right before client authentication
      - Final position in the sequence for the flow TBD
---
 .../context/OIDCAuthenticationResponseContext.java |  38 +++-
 .../messaging/context/OAuth2DPoPProofContext.java  |  74 ++++++++
 .../DefaultDPoPProofThumbprintLookupFunction.java  |  48 +++++
 .../DefaultRequestDPoPJktLookupFunction.java       |  63 +++++++
 .../op/token/support/AccessTokenClaimsSet.java     |   1 +
 .../op/token/support/RefreshTokenClaimsSet.java    |   1 +
 .../oidc/op/token/support/TokenClaimsSet.java      |  41 ++++-
 .../op/oauth2/profile/impl/BuildAccessToken.java   |   3 +-
 .../profile/impl/InitializeDPoPProofContext.java   | 202 +++++++++++++++++++++
 .../SetAuthorizationCodeToResponseContext.java     |   1 +
 .../profile/impl/StoreDPoPProofKeyThumbprint.java  | 142 +++++++++++++++
 .../op/oauth2/profile/impl/ValidateDPoPProof.java  | 158 ++++++++++++++++
 .../impl/SetRefreshTokenToResponseContext.java     |   1 +
 .../plugin/oidc/op/profile/impl/ValidateGrant.java |   1 +
 .../dpop-proof-validation-beans.xml                |  87 +++++++++
 .../dpop-proof-validation-flow.xml                 |  17 ++
 .../pushed-authorization-beans.xml                 |   3 +
 .../pushed-authorization-flow.xml                  |   1 +
 .../oidc/abstract-api/oidc-abstract-api-flow.xml   |   4 +-
 .../idp/flows/oidc/authorize/authorize-beans.xml   |   3 +
 .../idp/flows/oidc/authorize/authorize-flow.xml    |   1 +
 .../idp/service/relying-party/postconfig.xml       |  58 +++++-
 .../oidc/op/profile/flow/AbstractOidcFlowTest.java |  17 ++
 .../oidc/op/profile/flow/AuthorizeFlowTest.java    |  56 ++++++
 .../op/profile/flow/PushedAuthorizeFlowTest.java   |  38 ++++
 25 files changed, 1052 insertions(+), 7 deletions(-)

diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/messaging/context/OIDCAuthenticationResponseContext.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/messaging/context/OIDCAuthenticationResponseContext.java
index a8a93ec8..b4065096 100644
--- a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/messaging/context/OIDCAuthenticationResponseContext.java
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/messaging/context/OIDCAuthenticationResponseContext.java
@@ -29,6 +29,7 @@ import com.nimbusds.oauth2.sdk.AuthorizationCode;
 import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.oauth2.sdk.token.AccessToken;
 import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.oauth2.sdk.token.DPoPAccessToken;
 import com.nimbusds.oauth2.sdk.token.RefreshToken;
 import com.nimbusds.openid.connect.sdk.OIDCClaimsRequest;
 import com.nimbusds.openid.connect.sdk.claims.ACR;
@@ -131,6 +132,9 @@ public class OIDCAuthenticationResponseContext extends BaseContext {
     /** Whether request object has already been validated. */
     private boolean requestObjectValidated = false;
 
+    /** DPoP Proof JWK thumbprint. */
+    @Nullable private String dpopProofJwkThumbprint;
+
     /** Constructor. */
     public OIDCAuthenticationResponseContext() {
         validatedAudience = new ArrayList<>();
@@ -469,7 +473,15 @@ public class OIDCAuthenticationResponseContext extends BaseContext {
     */
    public void setAccessToken(@Nullable final String token, @Nonnull final Duration lifeTime,
            @Nullable final Scope scope) {
-       accessToken = token == null ? null : new BearerAccessToken(token, lifeTime.getSeconds(), scope);
+       if (token == null) {
+           accessToken = null;
+       } else {
+           if (getDpopProofJwkThumbprint() == null) {
+               accessToken = new BearerAccessToken(token, lifeTime.getSeconds(), scope);
+           } else {
+               accessToken = new DPoPAccessToken(token, lifeTime.getSeconds(), scope);
+           }
+       }
    }
    
    /**
@@ -573,5 +585,27 @@ public class OIDCAuthenticationResponseContext extends BaseContext {
     public void setRequestObjectValidated(final boolean flag) {
         requestObjectValidated = flag;
     }
-    
+
+    /**
+     * Get the DPoP Proof JWK thumbprint.
+     * 
+     * @return DPoP Proof JWK thumbprint
+     * 
+     * @since 4.2.0
+     */
+    @Nullable
+    public String getDpopProofJwkThumbprint() {
+        return dpopProofJwkThumbprint;
+    }
+
+    /**
+     * Set the DPoP Proof JWK thumbprint.
+     * 
+     * @param jkt DPoP Proof JWK thumbprint
+     * 
+     * @since 4.2.0
+     */
+    public void setDpopProofJwkThumbprint(@Nullable final String jkt) {
+        dpopProofJwkThumbprint = jkt;
+    }
 }
\ No newline at end of file
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/messaging/context/OAuth2DPoPProofContext.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/messaging/context/OAuth2DPoPProofContext.java
new file mode 100644
index 00000000..355f0f1f
--- /dev/null
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/messaging/context/OAuth2DPoPProofContext.java
@@ -0,0 +1,74 @@
+/*
+ * 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.idp.plugin.oidc.op.oauth2.messaging.context;
+
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+import org.opensaml.messaging.context.MessageContext;
+
+import com.nimbusds.jwt.SignedJWT;
+
+/**
+ * Subcontext carrying information for an OAuth2 DPoP Proof JWT.
+ * 
+ * <p>This context appears as a subcontext of a {@link MessageContext}.</p>
+ * 
+ * @since 4.2.0
+ */
+public class OAuth2DPoPProofContext extends BaseContext {
+
+    /** DPoP Proof JWT. */
+    @Nullable private SignedJWT dpopProof;
+
+    /** Validated DPoP Proof JWT JWK thumbprint. */
+    @Nullable private String dPopProofThumbprint;
+
+    /**
+     * Get the DPoP proof JWT.
+     * 
+     * @return DPoP proof JWT.
+     */
+    @Nullable public SignedJWT getDpopProof() {
+        return dpopProof;
+    }
+
+    /**
+     * Set the DPoP proof JWT.
+     * 
+     * @param jwt DPoP proof JWT
+     */
+    public void setDpopProof(@Nullable final SignedJWT jwt) {
+        dpopProof = jwt;
+    }
+
+    /**
+     * Get the validated DPoP Proof JWT JWK thumbprint.
+     * 
+     * @return validated DPoP Proof JWT JWK thumbprint.
+     */
+    @Nullable public String getValidatedDpopProofThumbprint() {
+        return dPopProofThumbprint;
+    }
+
+    /**
+     * Set the validated DPoP Proof JWT JWK thumbprint.
+     * 
+     * @param thumbprint validated DPoP Proof JWT JWK thumbprint.
+     */
+    public void setValidatedDpopProofThumbprint(@Nullable final String thumbprint) {
+        dPopProofThumbprint = thumbprint;
+    }
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultDPoPProofThumbprintLookupFunction.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultDPoPProofThumbprintLookupFunction.java
new file mode 100644
index 00000000..8afe8a6d
--- /dev/null
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultDPoPProofThumbprintLookupFunction.java
@@ -0,0 +1,48 @@
+/*
+ * 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.idp.plugin.oidc.op.profile.context.navigate;
+
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ContextDataLookupFunction;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+
+/**
+ * A function that returns validated DPoP proof JWK thumbprint if stored in {@link OAuth2DPoPProofContext}.
+ * 
+ * @since 4.2.0
+ */
+public class DefaultDPoPProofThumbprintLookupFunction  extends AbstractIdentifiableInitializableComponent
+    implements ContextDataLookupFunction<ProfileRequestContext, String> {
+
+    /** {@inheritDoc} */
+    @Override
+    public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        if (profileRequestContext != null) {
+            final MessageContext inboundContext = profileRequestContext.getInboundMessageContext();
+            if (inboundContext != null) {
+                final OAuth2DPoPProofContext proofContext = inboundContext.getSubcontext(OAuth2DPoPProofContext.class);
+                if (proofContext != null) {
+                    return proofContext.getValidatedDpopProofThumbprint();
+                }
+            }
+        }
+        return null;
+    }
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultRequestDPoPJktLookupFunction.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultRequestDPoPJktLookupFunction.java
new file mode 100644
index 00000000..68bc7463
--- /dev/null
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/context/navigate/DefaultRequestDPoPJktLookupFunction.java
@@ -0,0 +1,63 @@
+/*
+ * 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.idp.plugin.oidc.op.profile.context.navigate;
+
+import java.text.ParseException;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.oauth2.sdk.AuthorizationRequest;
+
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A function that returns dpop_jkt value of the authorization request via a lookup function. This default lookup
+ * locates dpop_jkt from OAuth authorization request if available. If information is not available, null is
+ * returned. If there is dpop_jkt in request object it is used instead of dpop_jkt parameter.
+ * 
+ * @since 4.2.0
+ */
+public class DefaultRequestDPoPJktLookupFunction extends AbstractAuthorizationRequestLookupFunction<String> {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(DefaultRequestDPoPJktLookupFunction.class);
+
+    /** {@inheritDoc} */
+    @Nullable protected String doLookup(@Nonnull final AuthorizationRequest req) {
+        final JWT requestObject = getRequestObject();
+        try {
+            if (requestObject != null && requestObject.getJWTClaimsSet().getClaim("dpop_jkt") != null) {
+                final Object thumbprint = requestObject.getJWTClaimsSet().getClaim("dpop_jkt");
+                if (thumbprint instanceof String string) {
+                    return string;
+                } else {
+                    log.error("dpop_jkt claim is not of expected type");
+                    return null;
+                }
+
+            }
+        } catch (final ParseException e) {
+            log.error("Unable to parse dpop_jkt from request object dpop_jkt value");
+            return null;
+        }
+        return req.getDPoPJWKThumbprintConfirmation() == null ?
+                null : req.getDPoPJWKThumbprintConfirmation().getValue().toString();
+    }
+
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/AccessTokenClaimsSet.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/AccessTokenClaimsSet.java
index 53fcb0b0..44d6d15f 100644
--- a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/AccessTokenClaimsSet.java
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/AccessTokenClaimsSet.java
@@ -174,6 +174,7 @@ public final class AccessTokenClaimsSet extends TokenClaimsSet {
             setConsentEnabled(existing.isConsentEnabled());
             setRootTokenIdentifier(existing.getRootTokenIdentifier());
             setSessionIdentifier(existing.getSessionIdentifier());
+            setDpopProofJwkThumbprint(existing.getDpopProofJwkThumbprint());
         }
         
         /**
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/RefreshTokenClaimsSet.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/RefreshTokenClaimsSet.java
index ad276d59..43b5299a 100644
--- a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/RefreshTokenClaimsSet.java
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/RefreshTokenClaimsSet.java
@@ -178,6 +178,7 @@ public final class RefreshTokenClaimsSet extends TokenClaimsSet {
             setConsentedClaims(existing.getConsentedClaims());
             setConsentEnabled(existing.isConsentEnabled());
             setSessionIdentifier(existing.getSessionIdentifier());
+            setDpopProofJwkThumbprint(existing.getDpopProofJwkThumbprint());
         }
 
         /**
diff --git a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/TokenClaimsSet.java b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/TokenClaimsSet.java
index ea9d1a7f..c6ca4c5f 100644
--- a/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/TokenClaimsSet.java
+++ b/idp-oidc-extension-api/src/main/java/net/shibboleth/idp/plugin/oidc/op/token/support/TokenClaimsSet.java
@@ -137,6 +137,9 @@ public class TokenClaimsSet {
     /** Identifier for the session id. */
     @Nonnull @NotEmpty public static final String KEY_SESSION_ID = "sid";
 
+    /** Identifier for the DPoP Proof JWK thumbprint. */
+    @Nonnull @NotEmpty public static final String KEY_DPOP_PROOF_JWK_THUMBPRINT = "jkt";
+
     /** Claims set for the claim. */
     @Nullable private JWTClaimsSet tokenClaimsSet;
 
@@ -230,6 +233,9 @@ public class TokenClaimsSet {
         if (tokenClaimsSet.getClaims().containsKey(KEY_CODE_CHALLENGE)) {
             tokenClaimsSet.getStringClaim(KEY_CODE_CHALLENGE);
         }
+        if (tokenClaimsSet.getClaims().containsKey(KEY_DPOP_PROOF_JWK_THUMBPRINT)) {
+            tokenClaimsSet.getStringClaim(KEY_DPOP_PROOF_JWK_THUMBPRINT);
+        }
 
     }
 // Checkstyle: CyclomaticComplexity ON
@@ -645,6 +651,21 @@ public class TokenClaimsSet {
         return (String) tokenClaimsSet.getClaim(KEY_SESSION_ID);
     }
 
+    /**
+     * Get the DPoP Proof JWK thumbprint.
+     * 
+     * @return the DPoP Proof JWK thumbprint.
+     * 
+     * @since 4.2.0
+     */
+    @Nullable public String getDpopProofJwkThumbprint() {
+        final JWTClaimsSet tokenClaimsSet = assertedClaimsSet();
+        if (tokenClaimsSet.getClaim(KEY_DPOP_PROOF_JWK_THUMBPRINT) == null) {
+            return null;
+        }
+        return (String) tokenClaimsSet.getClaim(KEY_DPOP_PROOF_JWK_THUMBPRINT);
+    }
+
     /**
      * Abstract builder to extend builders from that are instantiating claims sets extending TokenClaimsSet.
      * 
@@ -726,6 +747,9 @@ public class TokenClaimsSet {
         /** Session identifier. */
         @Nullable protected String sessionId;
 
+        /** DPoP Proof JWK thumbprint. */
+        @Nullable protected String dpopProofJwkThumbprint;
+
         /** Default constructor. */
         protected Builder() {
             audience = CollectionSupport.emptyList();
@@ -785,7 +809,8 @@ public class TokenClaimsSet {
                     .claim(KEY_CODE_CHALLENGE, codeChallenge)
                     .claim(KEY_CONSENT_ENABLED, consentEnabled)
                     .claim(KEY_ROOT_JTI, rootTokenId)
-                    .claim(KEY_SESSION_ID, sessionId);
+                    .claim(KEY_SESSION_ID, sessionId)
+                    .claim(KEY_DPOP_PROOF_JWK_THUMBPRINT, dpopProofJwkThumbprint);
             
             customClaims.forEach((n,v) -> {
                 if (n != null) {
@@ -1174,6 +1199,20 @@ public class TokenClaimsSet {
             return this;
         }
 
+        /**
+         * Set DPoP Proof JWK thumbprint.
+         * 
+         * @param jkt DPoP Proof JWK thumbprint
+         * 
+         * @return the builder
+         * 
+         * @since 4.2.0
+         */
+        public Builder<T> setDpopProofJwkThumbprint(@Nullable final String jkt) {
+            dpopProofJwkThumbprint = jkt;
+            return this;
+        }
+
         /**
          * Builds claims set.
          * 
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/BuildAccessToken.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/BuildAccessToken.java
index 47629f09..b56388dd 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/BuildAccessToken.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/BuildAccessToken.java
@@ -536,7 +536,8 @@ public class BuildAccessToken extends AbstractOIDCResponseAction {
                     .setDlClaimsUI(claimsUI)
                     .setConsentedClaims(consented)
                     .setConsentEnabled(consentEnabledPredicate.test(profileRequestContext))
-                    .setSessionIdentifier(responseCtx.getSessionId());
+                    .setSessionIdentifier(responseCtx.getSessionId())
+                    .setDpopProofJwkThumbprint(responseCtx.getDpopProofJwkThumbprint());
 
             assert subjectCtx != null;
             final String principal = subjectCtx.getPrincipalName();
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/InitializeDPoPProofContext.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/InitializeDPoPProofContext.java
new file mode 100644
index 00000000..e2bc3afd
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/InitializeDPoPProofContext.java
@@ -0,0 +1,202 @@
+/*
+ * 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.idp.plugin.oidc.op.oauth2.profile.impl;
+
+import java.text.ParseException;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.encoder.AbstractMessageEncoder;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.Request;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.plugin.oidc.op.encoding.impl.ResponseUtil;
+import net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext;
+import net.shibboleth.idp.plugin.oidc.op.profile.impl.AbstractOIDCRequestAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+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;
+
+/**
+ * Action that stores DPoP proof JWT to {@link OAuth2DPoPProofContext}. The value is extracted from the HTTP request
+ * header. If the DPoP-header contains a value, this action verifies that it's a single SignedJWT value as mandated by
+ * the specification.
+ * 
+ * @since 4.2.0
+ */
+public class InitializeDPoPProofContext extends AbstractOIDCRequestAction<Request> {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(InitializeDPoPProofContext.class);
+
+    /** Used to log protocol messages. */
+    @Nonnull private Logger protocolMessageLog =
+            LoggerFactory.getLogger(AbstractMessageEncoder.BASE_PROTOCOL_MESSAGE_LOGGER_CATEGORY + ".OAUTH2");
+
+    /** Predicate for enforcing the use of DPoP proofs. */
+    @NonnullAfterInit private Predicate<ProfileRequestContext> requireDpopProofCondition;
+
+    /** Object mapper used for pretty-printing JWT contents. */
+    @NonnullAfterInit private ObjectMapper objectMapper;
+
+    /**
+     * Set the predicate for enforcing the use of DPoP proofs.
+     * 
+     * @param predicate the predicate for enforcing the use of DPoP proofs
+     */
+    public void setRequireDpopProofCondition(@Nonnull final Predicate<ProfileRequestContext> predicate) {
+        requireDpopProofCondition = Constraint.isNotNull(predicate,
+                "DPoP proof enforced predicate annot be null");
+    }
+
+    /**
+     * Set the object mapper used for pretty-printing JWT contents.
+     * 
+     * @param mapper What to set.
+     */
+    public void setObjectMapper(@Nonnull final ObjectMapper mapper) {
+        checkSetterPreconditions();
+        objectMapper = Constraint.isNotNull(mapper, "Object mapper cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (requireDpopProofCondition == null) {
+            throw new ComponentInitializationException("DPoP proof enforced predicate cannot be null");
+        }
+        if (objectMapper == null) {
+            throw new ComponentInitializationException("Object mapper cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        
+        final HttpServletRequest httpServletRequest = getHttpServletRequest();
+        if (httpServletRequest == null) {
+            log.error("{} No HttpServletRequest available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        final boolean valueExists = httpServletRequest.getHeader("DPoP") != null;
+        final boolean requireDpopProof = requireDpopProofCondition.test(profileRequestContext);
+        if (!valueExists) {
+            if (!requireDpopProof) {
+                log.debug("{} No optional DPoP Proof header values exists, nothing to do", getLogPrefix());
+                return false;
+            }
+            log.warn("{} No mandatory DPoP Proof header value exists", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.MISSING_DPOP_PROOF);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final HttpServletRequest httpServletRequest = getHttpServletRequest();
+        assert httpServletRequest != null;
+
+        final String dpopProofValue = getSingleValueOrNull(httpServletRequest.getHeaders("DPoP"));
+        if (dpopProofValue == null) {
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.MISSING_DPOP_PROOF);
+            return;
+        }
+        final OAuth2DPoPProofContext proofContext = profileRequestContext
+                .ensureInboundMessageContext().ensureSubcontext(OAuth2DPoPProofContext.class);
+        final SignedJWT jwt;
+        try {
+            jwt = SignedJWT.parse(dpopProofValue);
+            if (jwt != null) {
+                proofContext.setDpopProof(jwt);
+                log.debug("{} DPoP Proof value {} stored into the response context", getLogPrefix(), dpopProofValue);
+                doProtocolLog(profileRequestContext, jwt, "DPoP proof JWT payload contents");
+                return;
+            } else {
+                log.warn("{} DPoP Proof value {} parsing returned a null JWT", getLogPrefix(), dpopProofValue);
+            }
+        } catch (ParseException e) {
+            log.warn("{} Could not parse the DPoP Proof value {} into a signed JWT", getLogPrefix(), dpopProofValue, e);
+        }
+        ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+    }
+
+    /**
+     * Returns the value from given enumeration if only single value exists, null otherwise.
+     * @param enumeration the input enumeration
+     * @return the single value if exists, null otherwise
+     */
+    @Nullable protected String getSingleValueOrNull(@Nullable final Enumeration<String> enumeration) {
+        if (enumeration == null) {
+            log.warn("{} No mandatory DPoP Proof header value exists", getLogPrefix());
+            return null;
+        }
+        final List<String> list = Collections.list(enumeration);
+        assert list != null;
+        if (list.isEmpty()) {
+            log.warn("{} No mandatory DPoP Proof header value exists", getLogPrefix());
+            return null;
+        }
+        if (list.size() > 1) {
+            log.warn("{} More than one DPoP Proof header value exists, not allowed", getLogPrefix());
+            return null;
+        }
+        return list.get(0);
+    }
+
+    /**
+     * Create a protocol message containing the pretty-printed contents of the given JWT.
+     * 
+     * @param profileRequestContext Profile request context where to publish possible error event
+     * @param jwt The token to be logged
+     * @param messagePrefix The prefix for the protocol log message
+     */
+    protected void doProtocolLog(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final JWT jwt, @Nonnull final String messagePrefix) {
+        try {
+            assert objectMapper != null;
+            protocolMessageLog.trace("{}:\n{}", messagePrefix, ResponseUtil.getJwtProtocolMessage(jwt, objectMapper));
+        } catch (ParseException e) {
+            log.error("{} Could not produce the protocol logger message", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+        }
+    }
+
+}
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/SetAuthorizationCodeToResponseContext.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/SetAuthorizationCodeToResponseContext.java
index bad17758..c95047c1 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/SetAuthorizationCodeToResponseContext.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/SetAuthorizationCodeToResponseContext.java
@@ -407,6 +407,7 @@ public class SetAuthorizationCodeToResponseContext extends AbstractOAuthAuthoriz
                 .setConsentedClaims(consented)
                 .setConsentEnabled(consentEnabledPredicate.test(profileRequestContext))
                 .setSessionIdentifier(responseCtx.getSessionId())
+                .setDpopProofJwkThumbprint(responseCtx.getDpopProofJwkThumbprint())
                 .build();
 
         if (manipulationStrategy != null) {
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/StoreDPoPProofKeyThumbprint.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/StoreDPoPProofKeyThumbprint.java
new file mode 100644
index 00000000..2671257a
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/StoreDPoPProofKeyThumbprint.java
@@ -0,0 +1,142 @@
+/*
+ * 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.idp.plugin.oidc.op.oauth2.profile.impl;
+
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.plugin.oidc.op.messaging.context.OIDCAuthenticationResponseContext;
+import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.DefaultDPoPProofThumbprintLookupFunction;
+import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.DefaultRequestDPoPJktLookupFunction;
+import net.shibboleth.oidc.profile.config.logic.RequireDPoPJktParameterPredicate;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
+
+/**
+ * Stores the dpop_jkt parameter from the incoming authorization request. It's optional by the spec, but it may be
+ * required via profile configuration settings.
+ */
+public class StoreDPoPProofKeyThumbprint  extends AbstractOAuthAuthorizationResponseAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(StoreDPoPProofKeyThumbprint.class);
+
+    /** Strategy used to determine whether to require dpop_jkt parameter. */
+    @Nonnull private Predicate<ProfileRequestContext> requireDpopJktCondition;
+
+    /** Strategy used to locate the dpop_jkt value. */
+    @Nonnull private Function<ProfileRequestContext, String> jktLookupStrategy;
+
+    /** Strategy used to locate thumbprint of validated DPoP Proof JWT. */
+    @Nonnull private Function<ProfileRequestContext, String> dpopProofThumbprintLookupStrategy;
+
+    /** Whether dpop_jkt parameter is mandatory. */
+    private boolean requireJkt;
+
+    /** The JWK thumbprint. */
+    @Nullable private String jwkThumbprint;
+
+    /**
+     * Constructor.
+     */
+    public StoreDPoPProofKeyThumbprint() {
+        requireDpopJktCondition = new RequireDPoPJktParameterPredicate();
+        jktLookupStrategy = new DefaultRequestDPoPJktLookupFunction();
+        dpopProofThumbprintLookupStrategy = new DefaultDPoPProofThumbprintLookupFunction();
+    }
+
+    /**
+     * Set the condition used to determine whether to require dpop_jkt parameter.
+     * 
+     * @param condition condition to apply
+     */
+    public void setRequireDpopJktCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        requireDpopJktCondition = Constraint.isNotNull(condition, "Condition cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the dpop_jkt value.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setDpopJktLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        jktLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the thumbprint of validated DPoP Proof JWT.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setDpopProofThumbprintLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        dpopProofThumbprintLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        requireJkt = requireDpopJktCondition.test(profileRequestContext);
+
+        jwkThumbprint = jktLookupStrategy.apply(profileRequestContext);
+
+        if (StringSupport.trimOrNull(jwkThumbprint) == null && !requireJkt) {
+            log.debug("{} No optional dpop_jkt in request, nothing to do", getLogPrefix());
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (StringSupport.trimOrNull(jwkThumbprint) == null) {
+            log.warn("{} No dpop_jkt in authorization request even though required by the profile configuration",
+                    getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.MISSING_DPOP_JKT);
+            return;
+        }
+        final String proofThumbprint = dpopProofThumbprintLookupStrategy.apply(profileRequestContext);
+        assert jwkThumbprint != null;
+        if (proofThumbprint != null && !jwkThumbprint.equals(proofThumbprint)) {
+            log.warn("{} dpop_jkt in authorization request does not match with the validated thumbprint",
+                    getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+            
+        }
+        final OIDCAuthenticationResponseContext oidcResponseContext = getOidcResponseContext();
+        assert oidcResponseContext != null;
+        oidcResponseContext.setDpopProofJwkThumbprint(jwkThumbprint);
+        log.debug("{} JWK thumbprint successfully stored into the context", getLogPrefix());
+    }
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/ValidateDPoPProof.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/ValidateDPoPProof.java
new file mode 100644
index 00000000..6475e947
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oauth2/profile/impl/ValidateDPoPProof.java
@@ -0,0 +1,158 @@
+/*
+ * 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.idp.plugin.oidc.op.oauth2.profile.impl;
+
+import java.text.ParseException;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.Request;
+
+import net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext;
+import net.shibboleth.idp.plugin.oidc.op.profile.impl.AbstractOIDCRequestAction;
+import net.shibboleth.oidc.jwt.claims.ClaimsValidator;
+import net.shibboleth.oidc.jwt.claims.JWTValidationException;
+import net.shibboleth.oidc.profile.config.navigate.DPoPProofClaimsValidatorLookupFunction;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action validates DPoP Proof JWT if already stored to the context. If validation succeeds, the JWK thumbprint is
+ * stored in the context.
+ */
+public class ValidateDPoPProof extends AbstractOIDCRequestAction<Request> {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(ValidateDPoPProof.class);
+
+    /** Lookup for the claims validator to be applied for validating the DPoP proof. */
+    @Nonnull private Function<ProfileRequestContext, ClaimsValidator> claimsValidatorLookupStrategy;
+
+    /** The claims validator to be applied for validating the DPoP proof. */
+    @Nullable private ClaimsValidator claimsValidator;
+
+    /**
+     * Constructor.
+     */
+    public ValidateDPoPProof() {
+        claimsValidatorLookupStrategy = new DPoPProofClaimsValidatorLookupFunction();
+    }
+    /**
+     * Set the lookup strategy for the claims validator used for validating the DPoP proof.
+     * 
+     * @param strategy What to set
+     */
+    public void setClaimsValidatorLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, ClaimsValidator> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        claimsValidatorLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        final OAuth2DPoPProofContext proofContext = profileRequestContext.ensureInboundMessageContext()
+                .getSubcontext(OAuth2DPoPProofContext.class);
+
+        if (proofContext == null || proofContext.getDpopProof() == null) {
+            log.debug("{} No DPoP Proof found from the response context, nothing to do", getLogPrefix());
+            return false;
+        }
+
+        claimsValidator = claimsValidatorLookupStrategy.apply(profileRequestContext);
+        if (claimsValidator == null) {
+            log.error("{} Could not resolve claims validator via lookup", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        return true;
+    }
+    
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final OAuth2DPoPProofContext proofContext = profileRequestContext.ensureInboundMessageContext()
+                .ensureSubcontext(OAuth2DPoPProofContext.class);
+        final SignedJWT dpopProof = proofContext.getDpopProof();
+        assert dpopProof != null;
+        final JWTClaimsSet jwtClaimsSet;
+        try {
+            jwtClaimsSet = dpopProof.getJWTClaimsSet();
+        } catch (ParseException e) {
+            log.warn("{} Could not parse claims set of DPoP Proof", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+        }
+        
+        final JWSHeader header = dpopProof.getHeader();
+        if (!new JOSEObjectType("dpop+jwt").equals(header.getType())) {
+            log.warn("{} Unexpected 'typ' parameter value {}", getLogPrefix(), header.getType());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+        }
+
+        final JWK jwk = header.getJWK();
+        if (jwk == null) {
+            log.warn("{} No value found from 'jwk' parameter value {}", getLogPrefix(), header.getType());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;            
+        }
+        if (jwk.isPrivate()) {
+            log.warn("{} Private key exists in 'jwk' parameter value {}", getLogPrefix(), header.getType());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;            
+        }
+        
+        assert jwtClaimsSet != null;
+        try {
+            assert claimsValidator != null;
+            claimsValidator.validate(jwtClaimsSet, profileRequestContext);
+        } catch (final JWTValidationException e) {
+            log.warn("{} DPoP Proof JWT validation failed: {}", getLogPrefix(), e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+        }
+
+        final String jwkThumbprint;
+        try {
+            jwkThumbprint = jwk.computeThumbprint().toString();
+        } catch (JOSEException e) {
+            log.error("{} Could not calculate thumbprint for the JWK", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+        }
+        proofContext.setValidatedDpopProofThumbprint(jwkThumbprint);
+        log.trace("{} JWK thumbprint stored in the context", getLogPrefix());
+    }    
+}
\ No newline at end of file
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
index a571a20d..1e4729d6 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/SetRefreshTokenToResponseContext.java
@@ -345,6 +345,7 @@ public class SetRefreshTokenToResponseContext extends AbstractOIDCResponseAction
         assert idGenerator != null;
         builder.setJWTID(idGenerator, xmlSafeIdentifier);
         builder.setRootTokenIdentifier(rootTokenId);
+        builder.setDpopProofJwkThumbprint(oidcResponseContext.getDpopProofJwkThumbprint());
         final RefreshTokenClaimsSet claimsSet = builder.build();
         
         if (manipulationStrategy != null) {
diff --git a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
index 9bebc8ec..2a565ccf 100644
--- a/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
+++ b/idp-oidc-extension-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/profile/impl/ValidateGrant.java
@@ -379,6 +379,7 @@ public class ValidateGrant extends AbstractOIDCTokenResponseAction {
         final OIDCAuthenticationResponseContext oidcResponseContext = getOidcResponseContext();
         assert oidcResponseContext != null;
         oidcResponseContext.setAuthorizationGrantClaimsSet(tokenClaimsSet);
+        oidcResponseContext.setDpopProofJwkThumbprint(tokenClaimsSet.getDpopProofJwkThumbprint());
     }
 // Checkstyle: CyclomaticComplexity|MethodLength|ReturnCount ON
 
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-beans.xml
new file mode 100644
index 00000000..ec8b49dc
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-beans.xml
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans" xmlns:c="http://www.springframework.org/schema/c"
+    xmlns:context="http://www.springframework.org/schema/context" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <bean id="InitializeDPoPProofContext" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.InitializeDPoPProofContext"
+        scope="prototype"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:objectMapper-ref="#{'%{idp.oidc.logging.objectMapper:shibboleth.oidc.JSONObjectMapper}'.trim()}">
+        <property name="requireDpopProofCondition">
+            <bean class="net.shibboleth.oidc.profile.config.logic.RequireDPoPProofPredicate"/>
+        </property>
+    </bean>
+
+    <bean id="DPoPSecurityParametersContextProfileRequestContextLookup" parent="shibboleth.Functions.Compose">
+        <constructor-arg name="g">
+            <ref bean="DPoPSecurityParametersContextMessageContextLookup" />
+        </constructor-arg>
+        <constructor-arg name="f">
+            <ref bean="shibboleth.MessageContextLookup.Inbound" />
+        </constructor-arg>
+    </bean>
+
+    <bean id="DPoPSecurityParametersContextMessageContextLookup" parent="shibboleth.Functions.Compose">
+        <constructor-arg name="g">
+            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
+                c:type="#{ T(net.shibboleth.oidc.security.jose.context.SecurityParametersContext) }"
+                c:createContext="true" />
+        </constructor-arg>
+        <constructor-arg name="f">
+            <bean class="org.opensaml.messaging.context.navigate.ChildContextLookup"
+                c:type="#{ T(net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext) }"/>
+        </constructor-arg>
+    </bean>
+
+    <bean id="PopulateDPoPProofSignatureValidationParameters"
+            class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureValidationParameters"
+            scope="prototype"
+            c:strategy-ref="shibboleth.MessageContextLookup.Inbound"
+            p:securityParametersContextLookupStrategy-ref="DPoPSecurityParametersContextProfileRequestContextLookup">
+        <property name="configurationLookupStrategy">
+            <bean class="net.shibboleth.oidc.profile.config.navigate.DPoPProofSignatureValidationConfigurationLookupFunction" />
+        </property>
+        <property name="signatureValidationParametersResolver">
+            <bean class="net.shibboleth.oidc.security.jose.impl.BasicSignatureValidationParametersResolver" />
+        </property>
+        <property name="activationCondition">
+            <bean parent="shibboleth.Conditions.Expression"
+                c:expression="#input.ensureInboundMessageContext().ensureSubcontext(T(net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext)).getDpopProof() != null" />
+        </property>
+    </bean>
+
+    <bean id="ValidateDPoPProofSignature" class="net.shibboleth.idp.profile.impl.WebFlowMessageHandlerAdaptor"
+        scope="prototype" c:executionDirection="INBOUND">
+        <constructor-arg>
+            <bean class="org.opensaml.messaging.handler.impl.BasicMessageHandlerChain">
+                <property name="handlers">
+                    <list>
+                        <bean class="net.shibboleth.oidc.security.impl.JWTMessageSignatureSecurityHandler"
+                            scope="prototype"
+                            p:securityParametersContextLookupStrategy-ref="DPoPSecurityParametersContextMessageContextLookup">
+                            <property name="jwtTokenLookupStrategy">
+                                <bean
+                                    class="net.shibboleth.profile.context.navigate.SpringExpressionContextLookupFunction"
+                                    c:_0="#{ T(org.opensaml.messaging.context.MessageContext) }"
+                                    c:outputType="#{T(com.nimbusds.jwt.SignedJWT)}"
+                                    c:expression="#input.getParent().ensureInboundMessageContext().ensureSubcontext(T(net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext)).getDpopProof()" />
+                            </property>
+                        </bean>
+                    </list>
+                </property>
+            </bean>
+        </constructor-arg>
+        <property name="activationCondition">
+            <bean parent="shibboleth.Conditions.Expression"
+                c:expression="#input.ensureInboundMessageContext().ensureSubcontext(T(net.shibboleth.idp.plugin.oidc.op.oauth2.messaging.context.OAuth2DPoPProofContext)).getDpopProof() != null" />
+        </property>
+    </bean>
+
+    <bean id="ValidateDPoPProof" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ValidateDPoPProof"
+        scope="prototype" />
+
+</beans>
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-flow.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-flow.xml
new file mode 100644
index 00000000..a4db6639
--- /dev/null
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-flow.xml
@@ -0,0 +1,17 @@
+<flow xmlns="http://www.springframework.org/schema/webflow" 
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+    abstract="true">
+
+    <action-state id="DoDPoPProofValidation">
+        <evaluate expression="InitializeDPoPProofContext" />
+        <evaluate expression="PopulateDPoPProofSignatureValidationParameters" />
+        <evaluate expression="ValidateDPoPProofSignature" />
+        <evaluate expression="ValidateDPoPProof" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="DoAuthenticationSubflow" />
+    </action-state>
+    
+    <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth2/dpop-proof-validation/dpop-proof-validation-beans.xml" />
+
+</flow>
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-beans.xml
index 31667fd0..37311a58 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-beans.xml
@@ -289,6 +289,9 @@
     <bean id="ValidateCodeChallenge" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ValidateCodeChallenge"
         scope="prototype" />
 
+    <bean id="StoreDPoPProofKeyThumbprint" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.StoreDPoPProofKeyThumbprint"
+        scope="prototype" />
+
     <bean id="FormOutboundMessage"
         class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.FormOutbounPushedAuthorizationResponseMessage"
         scope="prototype"
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-flow.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-flow.xml
index 907cf8be..04327da2 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-flow.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth2/pushed-authorization/pushed-authorization-flow.xml
@@ -50,6 +50,7 @@
         <evaluate expression="ValidateResponseType" />
         <evaluate expression="ValidateResponseMode" />
         <evaluate expression="ValidateCodeChallenge" />
+        <evaluate expression="StoreDPoPProofKeyThumbprint" />
         <evaluate expression="'proceed'" />
         
         <transition on="proceed" to="PopulateOutboundInterceptContext" />
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-flow.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-flow.xml
index 6900d8b1..4d733b29 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-flow.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/abstract-api/oidc-abstract-api-flow.xml
@@ -1,7 +1,7 @@
 <flow xmlns="http://www.springframework.org/schema/webflow" 
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
-    parent="oidc/abstract"
+    parent="oidc/abstract, oauth2/dpop-proof-validation"
     abstract="true">
 
     <!--  Actions common to most OIDC profile backend flows. -->
@@ -20,7 +20,7 @@
         <evaluate expression="InitializeAuthenticationContext" />
         <evaluate expression="'proceed'" />
         
-        <transition on="proceed" to="DoAuthenticationSubflow" />
+        <transition on="proceed" to="DoDPoPProofValidation" />
     </action-state>
 
     <subflow-state id="DoAuthenticationSubflow" subflow="authn">
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-beans.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-beans.xml
index 5796da38..a6a14945 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-beans.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-beans.xml
@@ -356,6 +356,9 @@
     <bean id="ValidateCodeChallenge" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ValidateCodeChallenge"
         scope="prototype" />
 
+    <bean id="StoreDPoPProofKeyThumbprint" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.StoreDPoPProofKeyThumbprint"
+        scope="prototype" />
+
     <bean id="ValidateScope" class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.ValidateScope" scope="prototype"
         p:allowedScopeLookupStrategy="#{getObject('shibboleth.oidc.AllowedScopeStrategy') ?: getObject('shibboleth.oidc.DefaultAllowedScopeStrategy')}" />
 
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-flow.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-flow.xml
index 7dab858e..954deb12 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-flow.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidc/authorize/authorize-flow.xml
@@ -68,6 +68,7 @@
         <evaluate expression="ValidateResponseType" />
         <evaluate expression="ValidateResponseMode" />
         <evaluate expression="ValidateCodeChallenge" />
+        <evaluate expression="StoreDPoPProofKeyThumbprint" />
         <evaluate expression="SetRequestedClaimsToResponseContext" />
         <evaluate expression="SetRequestedSubjectToResponseContext" />
         <evaluate expression="SetSessionIdToResponseContext" />
diff --git a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 13a2aac4..4dca5e0c 100644
--- a/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/idp-oidc-extension-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -88,7 +88,9 @@
         p:issuer-ref="shibboleth.oidc.issuer"
         p:tokenEndpointAuthMethods="%{idp.oidc.par.tokenEndpointAuthMethods:client_secret_basic,client_secret_post,client_secret_jwt,private_key_jwt}"
         p:claimsValidator-ref="DefaultJWTClaimsValidator"
-        p:unregisteredClientPolicy="#{getObject('shibboleth.oidc.DefaultUnregisteredClientPolicy')}" />
+        p:dpopProofClaimsValidator-ref="DefaultDPoPProofClaimsValidator"
+        p:unregisteredClientPolicy="#{getObject('shibboleth.oidc.DefaultUnregisteredClientPolicy')}"
+        p:dpopProofSignatureValidationConfiguration-ref="DPoPSignatureValidationConfiguration" />
 
     <bean id="DefaultLogoutHintMatchingPredicate"
           class="net.shibboleth.idp.plugin.oidc.op.profile.logic.DefaultLogoutHintMatchingPredicate"/>
@@ -256,6 +258,16 @@
                 p:propertyType="#{T(java.util.function.Function)}"
                 p:defaultValue-ref="shibboleth.oidc.DefaultUnregisteredClientPolicy" />
         </property>
+        <property name="dpopProofClaimsValidatorLookupStrategy">
+            <bean parent="shibboleth.MDDrivenBeanProperty" p:propertyName="dpopProofClaimsValidator"
+                p:propertyType="#{T(net.shibboleth.oidc.jwt.claims.ClaimsValidator)}"
+                p:defaultValue-ref="DefaultDPoPProofClaimsValidator" />
+        </property>
+        <property name="dpopProofSignatureValidationConfigurationLookupStrategy">
+            <bean parent="shibboleth.MDDrivenBeanProperty" p:propertyName="dpopProofSignatureValidationConfiguration"
+                p:propertyType="#{T(net.shibboleth.oidc.security.jose.SignatureValidationConfiguration)}"
+                p:defaultValue-ref="DPoPSignatureValidationConfiguration" />
+        </property>
     </bean>
     
     <bean id="OIDC.SSO.MDDriven" parent="AbstractMDDrivenOIDCSSOProfile" lazy-init="true"
@@ -661,6 +673,16 @@
                 p:propertyType="#{T(java.util.function.Function)}"
                 p:defaultValue-ref="shibboleth.oidc.DefaultUnregisteredClientPolicy" />
         </property>
+        <property name="dpopProofClaimsValidatorLookupStrategy">
+            <bean parent="shibboleth.MDDrivenBeanProperty" p:propertyName="dpopProofClaimsValidator"
+                p:propertyType="#{T(net.shibboleth.oidc.jwt.claims.ClaimsValidator)}"
+                p:defaultValue-ref="DefaultDPoPProofClaimsValidator" />
+        </property>
+        <property name="dpopProofSignatureValidationConfigurationLookupStrategy">
+            <bean parent="shibboleth.MDDrivenBeanProperty" p:propertyName="dpopProofSignatureValidationConfiguration"
+                p:propertyType="#{T(net.shibboleth.oidc.security.jose.SignatureValidationConfiguration)}"
+                p:defaultValue-ref="DPoPSignatureValidationConfiguration" />
+        </property>
     </bean>
 
     <!-- Default client-auth JWT validation wiring. -->
@@ -954,4 +976,38 @@
         </constructor-arg>
     </bean>
 
+    <bean id="DPoPSignatureValidationConfiguration"
+        parent="shibboleth.oidc.BasicSignatureValidationConfiguration"
+        p:signatureTrustEngine-ref="TokenAsymmetricKeyTrustEngineForDPoPJWT"/>
+
+    <bean id="TokenAsymmetricKeyTrustEngineForDPoPJWT"
+        class="net.shibboleth.oidc.security.impl.TokenAsymmetricKeyTrustEngine"
+        c:JOSEObjectResolver-ref="defaultSignedJWTJOSEHeaderCredentialResolver" />
+
+    <bean id="DefaultDPoPProofClaimsValidator"
+        class="net.shibboleth.oidc.security.jwt.claims.impl.ChainingJWTClaimsValidator"
+        p:claimValidators-ref="DPoPProofClaimsValidators" />
+
+    <util:list id="DPoPProofClaimsValidators" value-type="net.shibboleth.oidc.jwt.claims.ClaimsValidator">
+        <ref bean="ExpiryClaimsValidator" />
+        <ref bean="NotBeforeClaimsValidator" />
+        <ref bean="IssuedAtClaimsValidator" />
+        <bean id="JWTIdentifierClaimsValidator"
+            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}" />
+        <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator" p:claimName="htm">
+            <property name="valueToMatchLookupStrategy">
+                <bean parent="shibboleth.BiFunctions.Constant" c:target="POST" />
+            </property>
+        </bean>
+        <bean class="net.shibboleth.oidc.security.jwt.claims.impl.ExactMatchClaimsValidator" p:claimName="htu">
+            <property name="valueToMatchLookupStrategy">
+                <bean parent="shibboleth.BiFunctions.Expression" c:expression="#custom.get().getRequestURL().toString()"
+                    p:customObject-ref="shibboleth.HttpServletRequestSupplier"/>
+            </property>
+        </bean>
+    </util:list>
+
 </beans>
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcFlowTest.java
index 65d8faa9..ae0d9885 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AbstractOidcFlowTest.java
@@ -81,6 +81,8 @@ import com.nimbusds.oauth2.sdk.ResponseType;
 import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
 import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.dpop.DPoPProofFactory;
+import com.nimbusds.oauth2.sdk.dpop.DefaultDPoPProofFactory;
 import com.nimbusds.oauth2.sdk.id.ClientID;
 import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
 import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
@@ -601,4 +603,19 @@ public abstract class AbstractOidcFlowTest extends AbstractFlowTest {
         return new BearerAccessToken(claims.serialize(getDataSealer()));
     }
 
+    protected static SignedJWT buildDPoPProof(final String method, final String uri) {
+        try {
+            ECKey jwk = new ECKeyGenerator(Curve.P_256)
+                    .keyID("1")
+                    .generate();
+            DPoPProofFactory proofFactory = new DefaultDPoPProofFactory(
+                    jwk,
+                    JWSAlgorithm.ES256);
+            return proofFactory.createDPoPJWT(method, new URI(uri));
+        } catch (JOSEException | URISyntaxException e) {
+            Assert.fail("Could not create DPoP proof", e);
+        }
+        return null;
+    }
+
 }
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AuthorizeFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AuthorizeFlowTest.java
index ea8d57d5..18209036 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AuthorizeFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/AuthorizeFlowTest.java
@@ -1747,6 +1747,50 @@ public class AuthorizeFlowTest extends AbstractOidcFlowTest {
         Assert.assertNull(successResponse.getIssuer());
     }
 
+    @Test
+    public void testWithOptionalDPoPJkt_authzRequest() throws IOException, ParseException,
+            SessionException, JOSEException {
+        request.setMethod("GET");
+        setRequestParameters(List.of(new Pair<>("client_id", "mockClientId"),
+                new Pair<>("response_type", "code"),
+                new Pair<>("scope", "profile"),
+                new Pair<>("redirect_uri", redirectUri),
+                new Pair<>("dpop_jkt", "mockDPoPJktValue")));
+        storeMetadata(storageService, clientId, clientSecret, scope, redirectUri);
+
+        initializeThreadLocals();
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final AuthorizationResponse responseMessage = parseSuccessResponse(result, AuthorizationResponse.class);
+        final AuthorizationSuccessResponse successResponse = responseMessage.toSuccessResponse();
+        Assert.assertEquals(successResponse.getRedirectionURI().toString(), redirectUri);
+        Assert.assertNull(successResponse.getAccessToken());
+        Assert.assertNotNull(successResponse.getAuthorizationCode());
+        Assert.assertEquals(getJktFromAuthorizeCodeClaimsSet(successResponse), "mockDPoPJktValue");
+    }
+
+    @Test
+    public void testWithOptionalDPoPJkt_authnRequest() throws IOException, ParseException,
+            SessionException, JOSEException {
+        request.setMethod("GET");
+        setRequestParameters(List.of(new Pair<>("client_id", "mockClientId"),
+                new Pair<>("response_type", "code"),
+                new Pair<>("scope", "openid profile"),
+                new Pair<>("redirect_uri", redirectUri),
+                new Pair<>("dpop_jkt", "mockDPoPJktValue")));
+        storeMetadata(storageService, clientId, clientSecret, scope, redirectUri);
+
+        initializeThreadLocals();
+
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final AuthenticationResponse responseMessage = parseSuccessResponse(result, AuthenticationResponse.class);
+        final AuthenticationSuccessResponse successResponse = responseMessage.toSuccessResponse();
+        Assert.assertEquals(successResponse.getRedirectionURI().toString(), redirectUri);
+        Assert.assertNull(successResponse.getAccessToken());
+        Assert.assertNotNull(successResponse.getAuthorizationCode());
+        Assert.assertEquals(getJktFromAuthorizeCodeClaimsSet(successResponse), "mockDPoPJktValue");
+    }
+
     @Factory
     public Object[] createIdTokenSecurityTests() {
         return new Object[] {
@@ -1945,6 +1989,18 @@ public class AuthorizeFlowTest extends AbstractOidcFlowTest {
         }
     }
 
+    protected String getJktFromAuthorizeCodeClaimsSet(final AuthorizationSuccessResponse successResponse) {
+        Assert.assertNotNull(successResponse.getAuthorizationCode());
+        final AuthorizeCodeClaimsSet claims;
+        try {
+            claims = AuthorizeCodeClaimsSet.parse(successResponse.getAuthorizationCode().getValue(), getDataSealer());
+            Assert.assertNotNull(claims.getDpopProofJwkThumbprint());
+            return claims.getDpopProofJwkThumbprint();
+        } catch (ParseException | DataSealerException e) {
+            return null;
+        }
+    }
+
     protected List<String> getAudienceFromAuthorizeCodeClaimsSet(final AuthenticationSuccessResponse successResponse) {
         Assert.assertNotNull(successResponse.getAuthorizationCode());
         final AuthorizeCodeClaimsSet claims;
diff --git a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/PushedAuthorizeFlowTest.java b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/PushedAuthorizeFlowTest.java
index ea3ec55c..82476eb7 100644
--- a/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/PushedAuthorizeFlowTest.java
+++ b/idp-oidc-extension-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/PushedAuthorizeFlowTest.java
@@ -192,6 +192,44 @@ public class PushedAuthorizeFlowTest extends AbstractOidcClientAuthenticationFlo
         removeMetadata(storageService, "mockClientIdRequestObjectEnforced");
     }
 
+    @Test
+    public void testWithInvalidSyntaxDPoPProof() throws IOException, SessionException {
+        storeMetadata(storageService, clientId, clientSecret, scope, "https://example.org/cb");
+        setBasicAuth(clientId, clientSecret);
+        request.addHeader("DPoP", "invalid");
+        setHttpFormRequest("POST", createRequestParameters(clientId));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, OAuth2Error.INVALID_REQUEST_CODE);
+        removeMetadata(storageService, "mockClientIdRequestObjectEnforced");
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testWithInvalidDPoPProof_thunbprintNotMatching() throws IOException, SessionException {
+        storeMetadata(storageService, clientId, clientSecret, scope, "https://example.org/cb");
+        setBasicAuth(clientId, clientSecret);
+        request.addHeader("DPoP", buildDPoPProof("POST", "http://localhost/idp/profile/oauth2/pushed-authorization").serialize());
+        Map<String, String> requestParameters = createRequestParameters(clientId);
+        requestParameters.put("dpop_jkt", "notMatching");
+        setHttpFormRequest("POST", requestParameters);
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertErrorCode(result, OAuth2Error.INVALID_REQUEST_CODE);
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testWithValidDPoPProof() throws IOException, SessionException {
+        storeMetadata(storageService, clientId, clientSecret, scope, "https://example.org/cb");
+        setBasicAuth(clientId, clientSecret);
+        request.addHeader("DPoP", buildDPoPProof("POST", "http://localhost/idp/profile/oauth2/pushed-authorization").serialize());
+        setHttpFormRequest("POST", createRequestParameters(clientId));
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        assertSuccessResponse(result, clientId);
+        final PushedAuthorizationSuccessResponse response =
+                parseSuccessResponse(result, PushedAuthorizationSuccessResponse.class);
+        verifyAuthorizeEndpoint(clientId, response.getRequestURI().toString());
+    }
+
     @Factory
     public Object[] createRequestObjectSecurityTests() {
         return new Object[] {

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


More information about the commits mailing list