[java-idp-plugin-vci] 05/05: Make sure pre-authorization code is used only once

Codeberg noreply at shibboleth.net
Mon Sep 7 14:32:31 UTC 2026


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

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

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

commit 8eb43b5ef0dd2471a3403f3da98559fec925ff49
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Mon Sep 7 17:32:04 2026 +0300

    Make sure pre-authorization code is used only once
---
 .../openidvci/messaging/context/TokenContext.java  |  29 ++++-
 .../profile/impl/ConsumeCredentialOffer.java       | 120 ++++++++++++++++++
 .../plugin/openidvci/profile/impl/UnwrapGrant.java |   2 +-
 .../profile/impl/ValidatePreAuthorizedCode.java    | 137 +++++++++++++++++++++
 .../idp/flows/openid/vci/token/token-beans.xml     |   6 +
 .../idp/flows/openid/vci/token/token-flow.xml      |   2 +
 .../plugin/openidvci/conf/openid-vci.properties    |   2 +-
 7 files changed, 294 insertions(+), 4 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
index 361d11b..c81d2f5 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/TokenContext.java
@@ -22,6 +22,7 @@ import javax.annotation.Nullable;
 
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCIAuthorizationDetail;
 import org.geant.shibboleth.plugin.openidvci.storage.CredentialOfferObject;
+import org.geant.shibboleth.plugin.openidvci.token.support.CredentialOfferClaimsSet;
 import org.opensaml.messaging.context.BaseContext;
 
 import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
@@ -36,7 +37,11 @@ public class TokenContext extends BaseContext {
     /** Credential Offer being redeemed. */
     @Nullable
     private CredentialOfferObject credentialOfferObject;
-    
+
+    /** Claims of the self-contained pre-authorized code being redeemed. */
+    @Nullable
+    private CredentialOfferClaimsSet credentialOfferClaimsSet;
+
     /** Claims the issued token may carry. */
     @Nullable
     private ClaimsSet potentialCredentials;
@@ -63,7 +68,27 @@ public class TokenContext extends BaseContext {
     public void setCredentialOfferObject(@Nullable final CredentialOfferObject credentialOfferObject) {
         this.credentialOfferObject = credentialOfferObject;
     }
-    
+
+    /**
+     * Get the claims of the self-contained pre-authorized code being redeemed.
+     *
+     * @return claims of the pre-authorized code, or null
+     */
+    @Nullable
+    public CredentialOfferClaimsSet getCredentialOfferClaimsSet() {
+        return credentialOfferClaimsSet;
+    }
+
+    /**
+     * Set the claims of the self-contained pre-authorized code being redeemed.
+     *
+     * @param credentialOfferClaimsSet claims of the pre-authorized code to set
+     */
+    public void setCredentialOfferClaimsSet(@Nullable final CredentialOfferClaimsSet credentialOfferClaimsSet) {
+        this.credentialOfferClaimsSet = credentialOfferClaimsSet;
+    }
+
+
     /**
      * Get the claims the issued token may carry.
      *
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ConsumeCredentialOffer.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ConsumeCredentialOffer.java
new file mode 100644
index 0000000..797cb0a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ConsumeCredentialOffer.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCITokenRequest;
+import org.geant.shibboleth.plugin.openidvci.storage.CredentialOfferCache;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that spends the Credential Offer a pre-authorized code was redeemed
+ * for, making the code single use.
+ *
+ * <p>
+ * Runs once the tokens are built. Both the client of the request and the
+ * subject of the access token are resolved from the stored offer, so the offer
+ * outlives the actions reading it and is removed only after the last of them.
+ * </p>
+ *
+ * <p>
+ * Nothing is done for the authorization code flow, which carries no
+ * pre-authorized code, nor for a self-contained pre-authorized code, which has
+ * no stored offer to remove.
+ * </p>
+ */
+public class ConsumeCredentialOffer extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(ConsumeCredentialOffer.class);
+
+    /** Cache of Credential Offers. */
+    @NonnullAfterInit
+    private CredentialOfferCache credentialOfferCache;
+
+    /** Pre-authorized code the offer is keyed with. */
+    @NonnullBeforeExec
+    private String preAuthorizedCode;
+
+    /**
+     * Set the credential offer cache instance to use.
+     *
+     * @param cache The credential offer cache to set.
+     */
+    public void setCredentialOfferCache(@Nonnull final CredentialOfferCache cache) {
+        checkSetterPreconditions();
+        credentialOfferCache = Constraint.isNotNull(cache, "CredentialOfferCache cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (credentialOfferCache == null) {
+            throw new ComponentInitializationException("CredentialOfferCache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        if (profileRequestContext.getInboundMessageContext() == null) {
+            log.error("{} No inbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        if (profileRequestContext.getInboundMessageContext().getMessage() instanceof OpenIDVCITokenRequest request) {
+            preAuthorizedCode = request.getPreAuthorizedCode();
+        }
+        if (preAuthorizedCode == null) {
+            log.debug("{} No pre-authorized code in the request, nothing to consume", getLogPrefix());
+            return false;
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        try {
+            credentialOfferCache.removeCredentialOffer(preAuthorizedCode);
+            log.debug("{} Credential Offer of pre-authorized code {} consumed", getLogPrefix(), preAuthorizedCode);
+        } catch (final IOException e) {
+            log.error("{} Removing Credential Offer of pre-authorized code {} failed", getLogPrefix(),
+                    preAuthorizedCode, e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
index ea0b210..5b89a6d 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
@@ -201,6 +201,7 @@ public class UnwrapGrant extends AbstractOpenIDVCITokenResponseAction {
                 final TokenContext tokenContext = profileRequestContext.getInboundMessageContext()
                         .ensureSubcontext(TokenContext.class);
                 tokenContext.setCredentialOfferObject(CredentialOfferObject.parse(credentialOffer.getSubject()));
+                tokenContext.setCredentialOfferClaimsSet(credentialOffer);
                 getOidcResponseContext().setAuthTime(Instant.now());
                 setPotentialCredentials(tokenContext);
                 // Subject and auth time set for access token creation. TODO make sure what
@@ -219,7 +220,6 @@ public class UnwrapGrant extends AbstractOpenIDVCITokenResponseAction {
                         .ensureSubcontext(TokenContext.class);
                 tokenContext.setCredentialOfferObject(credentialOfferCache.getCredentialOffer(code));
                 setPotentialCredentials(tokenContext);
-                //credentialOfferCache.removeCredentialOffer(code);
                 // Subject and auth time set for access token creation. TODO make sure what
                 // content to use.
                 getOidcResponseContext().setSubject(clientIDLookupStrategy.apply(profileRequestContext).getValue());
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidatePreAuthorizedCode.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidatePreAuthorizedCode.java
new file mode 100644
index 0000000..b2c7200
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidatePreAuthorizedCode.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.TokenContext;
+import org.geant.shibboleth.plugin.openidvci.token.support.CredentialOfferClaimsSet;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.ReplayCache;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that spends a self-contained pre-authorized code, making it single
+ * use.
+ *
+ * <p>
+ * A self-contained code carries the Credential Offer within itself and has no
+ * stored offer to remove, so its identifier is recorded in the replay cache
+ * instead. A code whose identifier is already there has been redeemed before
+ * and is rejected.
+ * </p>
+ *
+ * <p>
+ * Nothing is done for a stored Credential Offer, which is spent by removing it,
+ * nor for the authorization code flow, which carries no pre-authorized code.
+ * </p>
+ *
+ * <p>
+ * Derived from the replay handling of {@code ValidateGrant} of the OpenID
+ * Connect Provider plugin, in package
+ * {@code net.shibboleth.idp.plugin.oidc.op.profile.impl}.
+ * </p>
+ */
+public class ValidatePreAuthorizedCode extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(ValidatePreAuthorizedCode.class);
+
+    /** Message replay cache instance to use. */
+    @NonnullAfterInit
+    private ReplayCache replayCache;
+
+    /** Claims of the code being redeemed. */
+    @NonnullBeforeExec
+    private CredentialOfferClaimsSet credentialOffer;
+
+    /**
+     * Set the replay cache instance to use.
+     *
+     * @param cache The replayCache to set.
+     */
+    public void setReplayCache(@Nonnull final ReplayCache cache) {
+        checkSetterPreconditions();
+        replayCache = Constraint.isNotNull(cache, "ReplayCache cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (replayCache == null) {
+            throw new ComponentInitializationException("ReplayCache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        if (profileRequestContext.getInboundMessageContext() == null) {
+            log.error("{} No inbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        final TokenContext tokenContext = profileRequestContext.getInboundMessageContext()
+                .getSubcontext(TokenContext.class);
+        if (tokenContext == null) {
+            log.error("{} No token context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        credentialOffer = tokenContext.getCredentialOfferClaimsSet();
+        if (credentialOffer == null) {
+            log.debug("{} Credential Offer not carried by the code itself, nothing to spend", getLogPrefix());
+            return false;
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final String jti = credentialOffer.getID();
+        if (jti == null) {
+            log.warn("{} Invalid contents in the pre-authorized grant: no JTI", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        final String cacheContext = getClass().getName();
+        assert cacheContext != null;
+        if (!replayCache.check(cacheContext, jti, credentialOffer.getExp())) {
+            log.error("{} Replay detected of pre-authorized code {}", getLogPrefix(), jti);
+            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+            return;
+        }
+        log.debug("{} Pre-authorized code {} spent", getLogPrefix(), jti);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
index 7dc307c..4fad799 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml
@@ -47,6 +47,9 @@
 
   <bean id="ValidateTxCode" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateTxCode"
         scope="prototype"/>
+
+  <bean id="ValidatePreAuthorizedCode" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidatePreAuthorizedCode"
+        scope="prototype" p:replayCache-ref="shibboleth.ReplayCache"/>
         
   <bean id="SetAuthorizationDetailsToResponseContext" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SetAuthorizationDetailsToResponseContext"
         scope="prototype"/>
@@ -98,6 +101,9 @@
         class="net.shibboleth.idp.plugin.oidc.op.oauth2.profile.impl.SetAccessTokenToResponseContext"
         scope="prototype" />
 
+  <bean id="ConsumeCredentialOffer" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ConsumeCredentialOffer"
+        scope="prototype" p:credentialOfferCache-ref="openidvci.CredentialOfferCache" />
+
   <bean id="FormOutboundMessage" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundTokenResponseMessage"
         scope="prototype" />
 
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
index 3eb8e61..bbd08a7 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
@@ -56,6 +56,7 @@
   <action-state id="PreAuthzGrantProcessing">
     <evaluate expression="ValidateExpectedGrantType"/>
     <evaluate expression="ValidateTxCode"/>
+    <evaluate expression="ValidatePreAuthorizedCode"/>
     <evaluate expression="'proceed'"/>
     <transition on="proceed" to="PostValidation"/>
   </action-state>
@@ -69,6 +70,7 @@
   <action-state id="BuildTokensForCredentialsAccess">
     <evaluate expression="BuildAccessToken" />
     <evaluate expression="SetOAuthAccessTokenToResponseContext" />
+    <evaluate expression="ConsumeCredentialOffer" />
     <evaluate expression="'proceed'" />
     <transition on="proceed" to="BuildResponseMessage" />
   </action-state>
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index 3bc3c82..97c9275 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -56,7 +56,7 @@ openidvci.signing.issuerMetadata.es.key = %{idp.home}/credentials/openid-vci-iss
 openidvci.status-list.index.permutationKey = change_this_to_secret
 # Default lifetime of Status List Tokens
 #openidvci.status-list.token.defaultLifetime = PT1H
-# Length of time to keep a slot assigned after the credential expires
+# Length of time an assignment record is kept for collection after it was written
 #openidvci.status-list.assignment.retention = P7D
 # Storage for status list state, for slot assignments and for the slots handed out.
 # Requires server-side storage

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


More information about the commits mailing list