[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