[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