[java-idp-plugin-vci] 03/03: VCI token endpoint specific ValidateGrant and strategy for it.

Codeberg noreply at shibboleth.net
Thu Dec 4 14:14:41 UTC 2025


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

codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.

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

commit d8e04ce6ac63c3ad8ee323a7b795ad09c88ce3d8
Author: jlauros <janne.lauros at csc.fi>
AuthorDate: Thu Dec 4 16:14:24 2025 +0200

    VCI token endpoint specific ValidateGrant and strategy for it.
---
 .../openidvci/profile/impl/ValidateGrant.java      | 315 +++++++++++++++++++++
 ...faultChainRevocationLifetimeLookupStrategy.java |  75 +++++
 2 files changed, 390 insertions(+)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateGrant.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateGrant.java
new file mode 100644
index 0000000..399341b
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateGrant.java
@@ -0,0 +1,315 @@
+/*
+ * 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.profile.impl;
+
+import java.time.Duration;
+import java.util.function.BiPredicate;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.AbstractOpenIDVCITokenResponseAction;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.DefaultChainRevocationLifetimeLookupStrategy;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.ReplayCache;
+import org.opensaml.storage.RevocationCache;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.GrantType;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+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.storage.RevocationCacheContexts;
+import net.shibboleth.idp.plugin.oidc.op.token.support.AuthorizeCodeClaimsSet;
+import net.shibboleth.idp.plugin.oidc.op.token.support.TokenClaimsSet;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.config.navigate.RevocationLifetimeLookupFunction;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.profile.context.RelyingPartyContext;
+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 validates an authorization grant.
+ * 
+ * <p>A grant is valid if it is successfully unwrapped, parsed as a code, is unexpired, was issued
+ * to the expected client and has not been used before (authz code) or the authz code used to produce it has not been
+ * revoked (refresh token).</p>
+ * 
+ * <p> The validated claims from the grant are stored to response context via
+ * {@link OIDCAuthenticationResponseContext#getAuthorizationGrantClaimsSet()}.</p>
+ * 
+ */
+public class ValidateGrant extends AbstractOpenIDVCITokenResponseAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(ValidateGrant.class);
+
+    /** Message replay cache instance to use. */
+    @NonnullAfterInit private ReplayCache replayCache;
+
+    /** Message revocation cache instance to use. */
+    @NonnullAfterInit private RevocationCache revocationCache;
+
+    /**
+     * Strategy used to locate the {@link RelyingPartyContext} associated with a given {@link ProfileRequestContext}.
+     */
+    @Nonnull private Function<ProfileRequestContext, RelyingPartyContext> relyingPartyContextLookupStrategy;
+
+
+    /** Lookup function to supply chain revocation lifetime. */
+    @Nonnull private Function<ProfileRequestContext,Duration> chainRevocationLifetimeLookupStrategy;
+
+
+    /** Strategy used to locate thumbprint of validated DPoP Proof JWT. */
+    @Nonnull private Function<ProfileRequestContext, String> dpopProofThumbprintLookupStrategy;
+
+    /** Predicate used to indicate whether the authorization code or refresh token is revoked. */
+    @Nullable private BiPredicate<ProfileRequestContext,JWTClaimsSet> tokenRevocationCondition;
+
+    /** The RelyingPartyContext to operate on. */
+    @Nullable private RelyingPartyContext rpCtx;
+
+    /** Refresh Token lifetime. */
+    @Nullable private Duration refreshTokenChainLifetime;
+
+    /**
+     * Constructor.
+     */
+    public ValidateGrant() {
+        relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
+        chainRevocationLifetimeLookupStrategy = new DefaultChainRevocationLifetimeLookupStrategy();
+        ((RevocationLifetimeLookupFunction) chainRevocationLifetimeLookupStrategy).setUseActiveProfileOnly(false);
+        dpopProofThumbprintLookupStrategy = new DefaultDPoPProofThumbprintLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the {@link RelyingPartyContext} associated with a given
+     * {@link ProfileRequestContext}.
+     * 
+     * @param strategy strategy used to locate the {@link RelyingPartyContext} associated with a given
+     *            {@link ProfileRequestContext}
+     */
+    public void setRelyingPartyContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, RelyingPartyContext> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        relyingPartyContextLookupStrategy =
+                Constraint.isNotNull(strategy, "RelyingPartyContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the replay cache instance to use.
+     * 
+     * @param cache The replayCache to set.
+     */
+    public void setReplayCache(@Nonnull final ReplayCache cache) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        replayCache = Constraint.isNotNull(cache, "ReplayCache cannot be null");
+    }
+
+    /**
+     * Set the revocation cache instance to use.
+     * 
+     * @param cache The revocationCache to set.
+     */
+    public void setRevocationCache(@Nonnull final RevocationCache cache) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        revocationCache = Constraint.isNotNull(cache, "RevocationCache cannot be null");
+    }
+
+    /**
+     * Set a lookup strategy for the chain revocation lifetime.
+     *
+     * @param strategy What to set.
+     */
+    public void setChainRevocationLifetimeLookupStrategy(
+            @Nullable final Function<ProfileRequestContext,Duration> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        chainRevocationLifetimeLookupStrategy = 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
+     * 
+     * @since 4.2.0
+     */
+    public void setDpopProofThumbprintLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        dpopProofThumbprintLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the predicate used to indicate whether the authorization code or refresh token is revoked.
+     * 
+     * @param condition token revocation condition
+     * 
+     * @since 4.3.0
+     */
+    public void setTokenRevocationCondition(@Nullable final BiPredicate<ProfileRequestContext,JWTClaimsSet> condition) {
+        checkSetterPreconditions();
+        tokenRevocationCondition = condition;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (replayCache == null || revocationCache == null) {
+            throw new ComponentInitializationException("ReplayCache and RevocationCache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        
+        rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
+        if (rpCtx == null) {
+            log.error("{} No relying party context associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+
+        return true;
+    }
+    
+// Checkstyle: CyclomaticComplexity|MethodLength|ReturnCount OFF
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final AuthorizationGrant grant = getTokenRequest().getAuthorizationGrant();
+        
+        log.debug("{} Validating grant type: {}", getLogPrefix(),grant.getType());
+
+        final OIDCAuthenticationResponseContext oidcResponseContext = getOidcResponseContext();
+        assert oidcResponseContext != null;
+        final TokenClaimsSet tokenClaimsSet = oidcResponseContext.getAuthorizationGrantClaimsSet();
+        if (GrantType.AUTHORIZATION_CODE.equals(grant.getType())) {
+            if (tokenClaimsSet instanceof AuthorizeCodeClaimsSet authzCodeClaimsSet) {
+                final String jti = authzCodeClaimsSet.getID();
+                if (jti == null) {
+                    log.warn("{} Invalid contents in the authz code grant: no JTI", getLogPrefix());
+                    ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                    return;
+                }
+                log.debug("{} Authz code unwrapped {}", getLogPrefix(), authzCodeClaimsSet.serialize());
+                final String cacheContext = getClass().getName();
+                assert cacheContext != null;
+                if (!replayCache.check(cacheContext, jti, authzCodeClaimsSet.getExp())) {
+                    log.error("{} Replay detected of authz code {}", getLogPrefix(), jti);
+                    if (!revokeChain(jti,
+                            chainRevocationLifetimeLookupStrategy.apply(profileRequestContext))) {
+                        log.warn("{} Fatal error, unable to save replayed code to revocation cache",
+                                getLogPrefix());
+                    }
+                    ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                    return;
+                }
+                final String dpopJkt = authzCodeClaimsSet.getDpopProofJwkThumbprint();
+                if (dpopJkt != null) {
+                    final String proofThumbprint = dpopProofThumbprintLookupStrategy.apply(profileRequestContext);
+                    if (!dpopJkt.equals(proofThumbprint)) {
+                        log.warn("{} The DPoP jkt in claims set '{}' did not match with the DPoP proof JWT '{}'",
+                                getLogPrefix(), dpopJkt, proofThumbprint);
+                        ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+                        return;
+                    }
+                }
+            } else {
+                log.error("{} Unexpected instance of token claims set for authorization code: {}",
+                        getLogPrefix(), tokenClaimsSet);
+                ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                return;
+            }
+        } 
+        
+        if (tokenClaimsSet == null) {
+            log.warn("{} Grant type not supported", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        if (!tokenClaimsSet.isTimeValid()) {
+            log.warn("{} Token is expired or not net valid", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        if (tokenRevocationCondition != null
+                && tokenRevocationCondition.test(profileRequestContext, tokenClaimsSet.getClaimsSet())) {
+            log.warn("{} Token has been revoked by the token revocation condition", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        final ClientID clientId = tokenClaimsSet.getClientID();
+        assert clientId != null;
+        assert rpCtx != null;
+        final String relyingPartyId = rpCtx.getRelyingPartyId();
+        if (!clientId.getValue().equals(relyingPartyId)) {
+            log.warn("{} Token issued to client {}, invalid for {}", getLogPrefix(),
+                    clientId.getValue(), relyingPartyId);
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        final String claimsSetThumbprint = tokenClaimsSet.getDpopProofJwkThumbprint();
+        final String proofThumbprint = dpopProofThumbprintLookupStrategy.apply(profileRequestContext);
+        if (claimsSetThumbprint == null) {
+            oidcResponseContext.setDpopProofJwkThumbprint(proofThumbprint);
+        } else if (proofThumbprint == null) {
+            oidcResponseContext.setDpopProofJwkThumbprint(claimsSetThumbprint);
+        } else if (!claimsSetThumbprint.equals(proofThumbprint)) {
+            log.warn("{} Invalid DPoP Proof thumbprint issued to client {}, invalid for {}", getLogPrefix(),
+                    clientId.getValue(), relyingPartyId);
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_DPOP_PROOF);
+            return;
+        } else {
+            oidcResponseContext.setDpopProofJwkThumbprint(proofThumbprint);
+        }
+    }
+// Checkstyle: CyclomaticComplexity|MethodLength|ReturnCount ON
+
+
+    /**
+     * Revokes the token chain with the given id, optionally with a given lifetime. If the given lifetime is null,
+     * the default lifetime set to the {@link RevocationCache} is used.
+     * 
+     * @param id The identifier to be revoked in {@link RevocationCacheContexts#AUTHORIZATION_CODE} context.
+     * @param lifetime The lifetime for the revocation
+     * @return The result returned by the {@link RevocationCache}
+     */
+    protected boolean revokeChain(@Nonnull final String id, @Nullable final Duration lifetime) {
+        if (lifetime == null) {
+            log.warn("{} No profile-specific revocation lifetime could be resolved, using default value",
+                    getLogPrefix());
+            return revocationCache.revoke(RevocationCacheContexts.AUTHORIZATION_CODE, id);
+        }
+        return revocationCache.revoke(RevocationCacheContexts.AUTHORIZATION_CODE, id, lifetime);
+    }
+    
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/DefaultChainRevocationLifetimeLookupStrategy.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/DefaultChainRevocationLifetimeLookupStrategy.java
new file mode 100644
index 0000000..3d0f4bd
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/DefaultChainRevocationLifetimeLookupStrategy.java
@@ -0,0 +1,75 @@
+/*
+ * 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.profile.logic;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.oidc.profile.config.navigate.RevocationLifetimeLookupFunction;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Default lookup function for fetching the chain revocation lifetime. This inherits the functionality of
+ * {@link RevocationLifetimeLookupFunction} but also adds the configurable clock skew value and additional 5 minutes
+ * to the returned value.
+ */
+public class DefaultChainRevocationLifetimeLookupStrategy extends RevocationLifetimeLookupFunction {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(DefaultChainRevocationLifetimeLookupStrategy.class);
+
+    /** Positive clock skew adjustment to consider when calculating revocation lifetime. */
+    @Nonnull private Duration clockSkew;
+
+    /**
+     * Constructor.
+     */
+    public DefaultChainRevocationLifetimeLookupStrategy() {
+        final Duration skew = Duration.ofMinutes(5);
+        assert skew != null;
+        clockSkew = skew;
+    }
+
+    /**
+     * Set the clock skew.
+     * 
+     * @param skew clock skew to set
+     */
+    public void setClockSkew(@Nonnull final Duration skew) {
+        final Duration newValue = Constraint.isNotNull(skew, "Clock skew cannot be null").abs();
+        assert newValue != null;
+        clockSkew = newValue;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public Duration apply(@Nullable final ProfileRequestContext input) {
+        final Duration profileDuration = super.apply(input);
+        if (profileDuration == null || profileDuration.isZero()) {
+            log.debug("No chain expiration time could be resolved, returning null");
+            return null;            
+        }
+        return profileDuration.plus(Duration.ofMinutes(5)).plus(clockSkew);
+    }
+    
+}
\ 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