[java-idp-plugin-vci] 01/01: Scope-limited offer callers, lock fixes for status list storage revealed in test runs, unwrapping not to log error level unless needed

Codeberg noreply at shibboleth.net
Thu Nov 5 13:43:38 UTC 2026


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

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

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

commit 0c856bcf2b14c495ce8bd49122d9c7c62dce8a1e
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Nov 5 15:41:57 2026 +0200

    Scope-limited offer callers, lock fixes for status list storage revealed in test runs, unwrapping not to log error level unless needed
---
 README.md                                          | 101 +++++++++++++--
 .../profile/impl/UnwrapPreAuthorizedCode.java      |  53 ++++----
 .../profile/impl/ValidateCredentialOfferScope.java | 140 +++++++++++++++++++++
 ...owedCredentialConfigurationsLookupFunction.java | 125 ++++++++++++++++++
 ...tboundStatusListAssignmentsResponseMessage.java |  56 ++++++++-
 .../storage/StatusListAssignmentStore.java         | 100 ++++++++++-----
 .../storage/StatusListIndexAllocator.java          |  52 +++++++-
 .../statuslist/storage/StatusListStore.java        |  68 +++++++++-
 .../META-INF/net.shibboleth.idp/postconfig.xml     |   4 +
 .../status-list/assignments/assignments-beans.xml  |   3 +-
 .../credential-offer/credential-offer-beans.xml    |   4 +
 .../vci/credential-offer/credential-offer-flow.xml |   1 +
 .../impl/ValidateCredentialOfferScopeTest.java     | 100 +++++++++++++++
 ...CredentialConfigurationsLookupFunctionTest.java |  75 +++++++++++
 ...ndStatusListAssignmentsResponseMessageTest.java | 139 ++++++++++++++++++++
 .../storage/StatusListAssignmentStoreTest.java     |  48 +++++++
 .../storage/StatusListIndexAllocatorTest.java      |  27 ++++
 .../statuslist/storage/StatusListStoreTest.java    |  32 +++++
 18 files changed, 1040 insertions(+), 88 deletions(-)

diff --git a/README.md b/README.md
index 173d774..d1b617c 100644
--- a/README.md
+++ b/README.md
@@ -980,6 +980,34 @@ Every configuration ID in the request must exist in *metadata/verifiable-credent
 otherwise, the plugin rejects the offer. The plugin does not resolve attributes when the wallet
 redeems the code. It issues the values provided in the offer.
 
+##### Limiting what the offer caller may offer
+
+**File(s):** *metadata/oidc-client.json*
+**Format:** JSON
+
+The `scope` of the offer caller limits the credentials it may offer. A credential configuration is
+allowed when its `scope` in *metadata/verifiable-credentials.json* is in the scope of the client.
+If you do not set scope to client it is same as having all scopes. It can create a credential offer
+for any of the known credential configurations. Also, such client is the only one that can create offer
+for credential configuration that has no scope.
+
+<details>
+<summary>metadata/oidc-client.json</summary>
+
+```json
+  {
+    "client_id": "offer-caller",
+    "client_name": "Credential offer caller",
+    "client_secret": "CHANGE-ME",
+    "scope": "GeantIncubatorDiploma_SDJWT GeantIncubatorDiploma_W3C"
+  }
+```
+
+</details>
+
+The same scope limits the assignment records the caller reads, see
+[Finding the slot of a credential](#finding-the-slot-of-a-credential).
+
 #### Authorization code flow
 
 **File(s):** *metadata/oidc-client.json*, *conf/relying-party.xml*, *conf/attribute-resolver.xml*, *conf/attribute-filter.xml*, *conf/oidc.properties*
@@ -1305,15 +1333,18 @@ The allocator does not initialise without a key
 and it warns for as long as the shipped placeholder is in place. If you change the key later,
 slots that are already used are handed out again.
 
-##### Finding the slot of a credential
+##### Keeping track of issued credentials
 
-A Status List has bits and nothing else in it, so the mapping from a credential to its slot is
-in the assignment records. The credential endpoint writes one record per issuance that took
-slots, and nothing in the IdP reads them back.
+A Status List holds only the bits. If you want at some point revoke credential from status list,
+you need the assignment records. The credential endpoint writes one record for each issuance, listing the slots the
+issued credentials took. The IdP itself does not use these records.
 
 Store the records in the system that tracks who holds each credential, and call the revoke API
 from there.
 
+A client with `scope` gets only the records of the credential configurations its scope allows, see
+[Limiting what the offer caller may offer](#limiting-what-the-offer-caller-may-offer). 
+
 Collect the records in the system that tracks who holds each credential:
 
 <details>
@@ -1352,10 +1383,49 @@ curl -u 'offer-caller:CHANGE-ME' \
 
 </details>
 
-Store `next` and give it back as `from` in the next call. `high_water` is where the records
-end, so `next == high_water` means you are up to date. Gaps in `seq` are normal. `expires` is
-the expiration of the credential that took the slots, which is also when revoking them stops
-mattering.
+Each record has a number, `seq`. The numbers increase by one for each new record. Response tells
+you where to continue: send its `next` as `from` in your next request. `high_water` is the
+number the next new record will get. When `next` is equal to `high_water`, you have reached the last
+record so far, and you can wait a while before you ask again. The script below works this way.
+
+A missing `seq` number is a record that has expired, or a record of a credential that the `scope`
+of your client does not allow.
+
+`expires` is when the credential expires. After that time, there is no need to revoke its slots.
+
+The same in a loop that keeps collecting. It prints each record as one line of JSON, put your own
+handling in its place:
+
+<details>
+<summary>Example of collecting assignment records continuously</summary>
+
+```bash
+#!/usr/bin/env bash
+# Prints each assignment record as it is written, needs curl and jq.
+URL=https://issuer.example.org/idp/profile/oauth/status-list/assignments
+CLIENT='offer-caller:CHANGE-ME'
+
+from=0
+while true; do
+    if ! page=$(curl -sS --fail -u "$CLIENT" -H 'Content-Type: application/json' \
+            -d "{\"from\": $from, \"limit\": 1000}" "$URL"); then
+        sleep 60
+        continue
+    fi
+    if [ "$(echo "$page" | jq '.records | length')" -eq 0 ]; then
+        echo "$(date +%T) no new credentials"
+    fi
+    echo "$page" | jq -c '.records[]'
+    from=$(echo "$page" | jq '.next')
+    if [ "$from" -ge "$(echo "$page" | jq '.high_water')" ]; then
+        sleep 60
+    fi
+done
+```
+
+</details>
+
+You can run the script in your terminal, when testing of course, to follow how credentials are issued.
 
 Assignment records expire `openidvci.status-list.assignment.retention` (`P7D`) after they are
 **written**, not after the credential expires. They are the only record of which slot a
@@ -1386,8 +1456,9 @@ belongs to the month regardless of the credential’s issuer ID.
 
 Revocation is idempotent and irreversible. Nothing in the plugin turns a slot back to
 valid. The revoke endpoint does **not** check that the caller issued the credential that holds
-the slot. Any client that has **OAuth.StatusList.Revoke** enabled may revoke any slot of any
-list, so enable it in the relying party that owns the issuance and nowhere else.
+the slot, and the `scope` of the caller does not limit it. Any client that has
+**OAuth.StatusList.Revoke** enabled may revoke any slot of any list. Enable it only for the client
+of the system that decides revocations, not for offer callers limited by scope.
 
 ##### Reading the list back
 
@@ -4627,7 +4698,9 @@ Request, a `POST` of `application/json`:
 | `txCode` | Object | `value`, and hints `input_mode` (`numeric` or `text`), `length` and `description`. `value` is not in the offer. |
 | `client_id` | String | Only where unauthenticated callers are accepted. |
 
-Response is the Credential Offer as an `openid-credential-offer://` deep link.
+Response is the Credential Offer as an `openid-credential-offer://` deep link. An offer of a
+credential configuration whose `scope` is not in the `scope` of the client is refused with
+`400 invalid_scope`, a client without `scope` may offer every credential configuration.
 
 #### OpenID.VCI.Token
 
@@ -4714,7 +4787,8 @@ Request, a `POST` of `application/json`:
 | `limit` | Integer | Records to return, `1` to `1000`. Default `100`. |
 | `client_id` | String | Only where unauthenticated callers are accepted. |
 
-Response has `records`, `next` and `high_water`. Give `next` as `from` of the next call.
+Response has `records`, `next` and `high_water`. Give `next` as `from` of the next call. For a
+client with `scope`, records of credential configurations whose `scope` is not in it are left out.
 
 #### OAuth.StatusList.Revoke
 
@@ -4735,6 +4809,8 @@ Request, a `POST` of `application/json`, success is `204 No Content`:
 | `idx` | Integer | The slot, `0` to `1048575`. |
 | `client_id` | String | Only where unauthenticated callers are accepted. |
 
+The `scope` of the client does not limit the slots it may revoke.
+
 ### Credential Configuration settings
 
 **File(s):** *metadata/verifiable-credentials.json*
@@ -4809,6 +4885,7 @@ Beans you can define or reference. Define your own beans in *conf/global.xml* or
 |---|---|---|---|
 | **openidvci.TokenManipulationStrategy** | Function | built in | Carries authorization details and claim values into the authorization code, the access token and the refresh token. For a wallet with a Wallet Attestation it binds the tokens to the wallet instance; a refresh from another instance is refused. Referenced from **OIDC.SSO** and **OpenID.VCI.Token**. |
 | **openidvci.ClientInstanceKeyThumbprintLookup** | Function | built in | Identifies the wallet instance of the Wallet Attestation of the request. |
+| **openidvci.AllowedCredentialConfigurationsLookup** | Function | built in | Credential configurations whose `scope` is in the `scope` of the client, all of them for a client without `scope`. Limits **OpenID.VCI.CredentialOffer** and **OAuth.StatusList.Assignments**. |
 | **openidvci.CredentialConfigurationsResolver** | CredentialConfigurationsResolver | built in | Reads *metadata/verifiable-credentials.json*. |
 | **openidvci.SigningCredentials** | List<Credential> | | Keys signing credentials. |
 | **openidvci.status-list.SigningCredentials** | List<Credential> | | Keys signing Status List Tokens. |
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapPreAuthorizedCode.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapPreAuthorizedCode.java
index 4eff8c4..61717bd 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapPreAuthorizedCode.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapPreAuthorizedCode.java
@@ -139,57 +139,56 @@ public class UnwrapPreAuthorizedCode extends AbstractOpenIDVCITokenResponseActio
         if (PreAuthorizedCodeGrant.GRANT_TYPE.equals(grant.getType())) {
             final String code = getOpenIDVCITokenRequest().getPreAuthorizedCode();
             log.info("{} Unwrapping pre-authorization code", getLogPrefix());
-            final Exception e;
+
+            final CredentialOfferObject storedOffer;
             try {
-                final CredentialOfferClaimsSet credentialOffer = CredentialOfferClaimsSet.parse(code, dataSealer);
-                assert credentialOffer != null;
-                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;
-                }
-                log.debug("{} pre-authorized grant unwrapped {}", getLogPrefix(), credentialOffer.serialize());
-                validateTokenClaimsSet(profileRequestContext, credentialOffer);
+                storedOffer = credentialOfferCache.getCredentialOffer(code);
+            } catch (final IOException | net.minidev.json.parser.ParseException e) {
+                log.error("{} Validating pre-authorized code failed", getLogPrefix(), e);
+                ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
+                return;
+            }
+            if (storedOffer != null) {
                 final TokenContext tokenContext = profileRequestContext.getInboundMessageContext()
                         .ensureSubcontext(TokenContext.class);
-                tokenContext.setCredentialOfferObject(CredentialOfferObject.parse(credentialOffer.getSubject()));
-                tokenContext.setCredentialOfferClaimsSet(credentialOffer);
-                getOidcResponseContext().setAuthTime(Instant.now());
+                tokenContext.setCredentialOfferObject(storedOffer);
                 setPotentialCredentials(tokenContext);
                 // Subject and auth time set for access token creation. TODO make sure what
                 // content to use.
                 getOidcResponseContext().setSubject(clientIDLookupStrategy.apply(profileRequestContext).getValue());
                 getOidcResponseContext().setAuthTime(Instant.now());
                 return;
-            } catch (final DataSealerException | JsonProcessingException | com.nimbusds.oauth2.sdk.ParseException
-                    | ParseException e2) {
-                log.warn("{} Unwrapping authz code failed: {}, might be storage based", getLogPrefix(),
-                        e2.getMessage());
             }
+            log.debug("{} No stored Credential Offer for the pre-authorized code, unwrapping it as a sealed code",
+                    getLogPrefix());
 
             try {
-                final CredentialOfferObject credentialOffer = credentialOfferCache.getCredentialOffer(code);
-                if (credentialOffer == null) {
-                    log.warn("{} No Credential Offer for the pre-authorized code, expired or already redeemed",
-                            getLogPrefix());
+                final CredentialOfferClaimsSet credentialOffer = CredentialOfferClaimsSet.parse(code, dataSealer);
+                assert credentialOffer != null;
+                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;
                 }
+                log.debug("{} pre-authorized grant unwrapped {}", getLogPrefix(), credentialOffer.serialize());
+                validateTokenClaimsSet(profileRequestContext, credentialOffer);
                 final TokenContext tokenContext = profileRequestContext.getInboundMessageContext()
                         .ensureSubcontext(TokenContext.class);
-                tokenContext.setCredentialOfferObject(credentialOffer);
+                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
                 // content to use.
                 getOidcResponseContext().setSubject(clientIDLookupStrategy.apply(profileRequestContext).getValue());
                 getOidcResponseContext().setAuthTime(Instant.now());
                 return;
-            } catch (final IOException | net.minidev.json.parser.ParseException e1) {
-                e = e1;
+            } catch (final DataSealerException | JsonProcessingException | com.nimbusds.oauth2.sdk.ParseException
+                    | ParseException e) {
+                log.warn("{} Unwrapping pre-authorized code failed, not a stored Credential Offer and not a sealed "
+                        + "one, expired or already redeemed: {}", getLogPrefix(), e.getMessage());
             }
-            log.error("{} Validating pre-authorized code failed", getLogPrefix(), e);
-            ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
         }
         ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_GRANT);
 
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScope.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScope.java
new file mode 100644
index 0000000..33d54c9
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScope.java
@@ -0,0 +1,140 @@
+/*
+ * 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.util.Set;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialOfferContext;
+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.oidc.profile.core.OidcEventIds;
+import net.shibboleth.profile.context.navigate.RelyingPartyIdLookupFunction;
+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 validates the Credential Configurations of a Credential Offer against those allowed for the client.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link OidcEventIds#INVALID_SCOPE}
+ */
+public class ValidateCredentialOfferScope extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(ValidateCredentialOfferScope.class);
+
+    /** Strategy used to obtain the client ID. */
+    @Nonnull
+    private Function<ProfileRequestContext, String> relyingPartyIdLookupStrategy;
+
+    /** Strategy used to obtain the Credential Configurations allowed for the client. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, Set<String>> allowedCredentialConfigurationsLookupStrategy;
+
+    /** Context of the Credential Offer. */
+    @NonnullBeforeExec
+    private CredentialOfferContext ctx;
+
+    /** Constructor. */
+    public ValidateCredentialOfferScope() {
+        relyingPartyIdLookupStrategy = new RelyingPartyIdLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to obtain the client ID.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setRelyingPartyIdLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        checkSetterPreconditions();
+        relyingPartyIdLookupStrategy = Constraint.isNotNull(strategy,
+                "Relying party ID lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the Credential Configurations allowed for the client.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setAllowedCredentialConfigurationsLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, Set<String>> strategy) {
+        checkSetterPreconditions();
+        allowedCredentialConfigurationsLookupStrategy = Constraint.isNotNull(strategy,
+                "Allowed Credential Configurations lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (allowedCredentialConfigurationsLookupStrategy == null) {
+            throw new ComponentInitializationException(
+                    "Allowed Credential Configurations lookup strategy cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        ctx = profileRequestContext.getInboundMessageContext() == null ? null
+                : profileRequestContext.getInboundMessageContext().getSubcontext(CredentialOfferContext.class);
+        if (ctx == null || ctx.getValidatedPreAuthorizedCredentials() == null) {
+            log.error("{} No credential offer context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        assert ctx != null;
+        final String clientId = relyingPartyIdLookupStrategy.apply(profileRequestContext);
+        final Set<String> allowed = allowedCredentialConfigurationsLookupStrategy.apply(profileRequestContext);
+        if (allowed == null) {
+            log.debug("{} No allowed scope for client {}, nothing to do", getLogPrefix(), clientId);
+            return;
+        }
+        for (final String configurationId : ctx.getValidatedPreAuthorizedCredentials().keySet()) {
+            if (!allowed.contains(configurationId)) {
+                log.warn("{} Credential Configuration {} is not in the scope of client {}", getLogPrefix(),
+                        configurationId, clientId);
+                ActionSupport.buildEvent(profileRequestContext, OidcEventIds.INVALID_SCOPE);
+                return;
+            }
+        }
+        log.debug("{} Credential Configurations {} are in the scope of client {}", getLogPrefix(),
+                ctx.getValidatedPreAuthorizedCredentials().keySet(), clientId);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunction.java
new file mode 100644
index 0000000..a5b2afc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunction.java
@@ -0,0 +1,125 @@
+/*
+ * 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.logic;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.oauth2.sdk.Scope;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.ClientInfoScopeLookupFunction;
+import net.shibboleth.idp.plugin.oidc.op.profile.context.navigate.DefaultOIDCMetadataContextLookupFunction;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Identifiers of the Credential Configurations whose 'scope' is in the scope registered for the client.
+ */
+public class AllowedCredentialConfigurationsLookupFunction implements Function<ProfileRequestContext, Set<String>> {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(AllowedCredentialConfigurationsLookupFunction.class);
+
+    /** Resolver of the Credential Configurations. */
+    @Nonnull
+    private final CredentialConfigurationsResolver credentialConfigurationsResolver;
+
+    /** Strategy used to obtain the scope allowed for the client. */
+    @Nonnull
+    private Function<ProfileRequestContext, Scope> allowedScopeLookupStrategy;
+
+    /**
+     * Constructor.
+     *
+     * @param resolver resolver of the Credential Configurations
+     */
+    public AllowedCredentialConfigurationsLookupFunction(@Nonnull final CredentialConfigurationsResolver resolver) {
+        credentialConfigurationsResolver = Constraint.isNotNull(resolver,
+                "Credential Configurations resolver cannot be null");
+        final Function<ProfileRequestContext, Scope> asls = new ClientInfoScopeLookupFunction().compose(
+                new DefaultOIDCMetadataContextLookupFunction());
+        assert asls != null;
+        allowedScopeLookupStrategy = asls;
+    }
+
+    /**
+     * Set the strategy used to obtain the scope allowed for the client.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setAllowedScopeLookupStrategy(@Nonnull final Function<ProfileRequestContext, Scope> strategy) {
+        allowedScopeLookupStrategy = Constraint.isNotNull(strategy, "Allowed scope lookup strategy cannot be null");
+    }
+
+    /**
+     * Get the identifiers of the Credential Configurations allowed for the client.
+     *
+     * @param profileRequestContext current profile request context
+     * @return identifiers of the allowed Credential Configurations, null if the client has no scope registered
+     */
+    @Override
+    @Nullable
+    public Set<String> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        final Scope allowedScope = allowedScopeLookupStrategy.apply(profileRequestContext);
+        if (allowedScope == null || allowedScope.isEmpty()) {
+            log.debug("No allowed scope for the client, Credential Configurations not restricted");
+            return null;
+        }
+
+        final CredentialConfigurations configurations;
+        try {
+            if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+                refreshable.refresh();
+            }
+            configurations = credentialConfigurationsResolver.resolveSingle(profileRequestContext);
+        } catch (final ResolverException e) {
+            log.error("Resolving Credential Configurations failed, none allowed", e);
+            return Collections.emptySet();
+        }
+        if (configurations == null) {
+            log.error("No Credential Configurations resolved, none allowed");
+            return Collections.emptySet();
+        }
+
+        final Set<String> allowed = new HashSet<>();
+        for (final Map.Entry<String, CredentialConfiguration> entry : configurations.getCredentialConfigurations()
+                .entrySet()) {
+            final String scope = entry.getValue().getScope();
+            if (scope != null && allowedScope.contains(scope)) {
+                allowed.add(entry.getKey());
+            }
+        }
+        log.debug("Credential Configurations allowed by scope {}: {}", allowedScope, allowed);
+        return allowed;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
index 820ab13..a8889fa 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
@@ -17,6 +17,8 @@
 package org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl;
 
 import java.io.IOException;
+import java.util.Set;
+import java.util.function.Function;
 
 import javax.annotation.Nonnull;
 
@@ -29,6 +31,7 @@ import net.shibboleth.shared.primitive.LoggerFactory;
 
 import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListAssignmentsRequest;
 import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListAssignmentsSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignment;
 import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignmentStore;
 import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignments;
 import org.opensaml.profile.action.ActionSupport;
@@ -38,11 +41,16 @@ import org.slf4j.Logger;
 
 /**
  * Action reading a page of records of status list assignments and forming
- * {@link StatusListAssignmentsSuccessResponse}.
+ * {@link StatusListAssignmentsSuccessResponse}, leaving out records of Credential Configurations not allowed for the
+ * client.
  *
  */
 public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractProfileAction {
 
+    /** Name of the Credential Configuration identifier in the details of a record. */
+    @Nonnull
+    private static final String CREDENTIAL_CONFIGURATION_ID = "credential_configuration_id";
+
     /** Class logger. */
     @Nonnull
     private Logger log = LoggerFactory.getLogger(FormOutboundStatusListAssignmentsResponseMessage.class);
@@ -51,10 +59,19 @@ public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractPr
     @NonnullAfterInit
     private StatusListAssignmentStore statusListAssignmentStore;
 
+    /** Strategy used to obtain the Credential Configurations allowed for the client. */
+    @Nonnull
+    private Function<ProfileRequestContext, Set<String>> allowedCredentialConfigurationsLookupStrategy;
+
     /** Request being served. */
     @NonnullBeforeExec
     private StatusListAssignmentsRequest request;
 
+    /** Constructor. */
+    public FormOutboundStatusListAssignmentsResponseMessage() {
+        allowedCredentialConfigurationsLookupStrategy = prc -> null;
+    }
+
     /**
      * Set store holding the records.
      *
@@ -65,6 +82,18 @@ public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractPr
         statusListAssignmentStore = Constraint.isNotNull(store, "StatusListAssignmentStore cannot be null");
     }
 
+    /**
+     * Set the strategy used to obtain the Credential Configurations allowed for the client.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setAllowedCredentialConfigurationsLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, Set<String>> strategy) {
+        checkSetterPreconditions();
+        allowedCredentialConfigurationsLookupStrategy = Constraint.isNotNull(strategy,
+                "Allowed Credential Configurations lookup strategy cannot be null");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -106,11 +135,30 @@ public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractPr
             return;
         }
 
+        final Set<String> allowed = allowedCredentialConfigurationsLookupStrategy.apply(profileRequestContext);
+        final StatusListAssignments visible = allowed == null ? records
+                : new StatusListAssignments(records.getRecords().stream()
+                        .filter(assignment -> isAllowed(assignment, allowed)).toList(), records.getNext(),
+                        records.getHighWater());
+
         profileRequestContext.ensureOutboundMessageContext()
-                .setMessage(new StatusListAssignmentsSuccessResponse(records));
+                .setMessage(new StatusListAssignmentsSuccessResponse(visible));
 
-        log.debug("{} Returning {} record(s), continuing at {} of {}", getLogPrefix(), records.getRecords().size(),
-                records.getNext(), records.getHighWater());
+        log.debug("{} Returning {} of {} record(s), continuing at {} of {}", getLogPrefix(),
+                visible.getRecords().size(), records.getRecords().size(), records.getNext(), records.getHighWater());
+    }
+
+    /**
+     * Whether the record is of a Credential Configuration allowed for the client.
+     *
+     * @param assignment the record
+     * @param allowed identifiers of the allowed Credential Configurations
+     * @return whether the record is allowed
+     */
+    private boolean isAllowed(@Nonnull final StatusListAssignment assignment, @Nonnull final Set<String> allowed) {
+        final Object configurationId = assignment.getDetails() == null ? null
+                : assignment.getDetails().get(CREDENTIAL_CONFIGURATION_ID);
+        return configurationId instanceof String id && allowed.contains(id);
     }
 
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStore.java
index 308ec71..9b21954 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStore.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStore.java
@@ -21,6 +21,8 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import javax.annotation.Nonnull;
 
@@ -44,9 +46,10 @@ import com.fasterxml.jackson.core.JsonProcessingException;
  * Store handing {@link StatusListAssignment} records to a collector outside the
  * IdP, backed by Shibboleth {@link StorageService}.
  *
- * Records are numbered each taking the next position of a sequence stored with
- * them. A collector reads page from a position it remembers and continues from
- * the end of the page.
+ * Records are numbered each taking the first free position from a sequence stored
+ * with them. The sequence moves past a position only once its record is stored, so
+ * every position below it holds a record until the record expires. A collector
+ * reads page from a position it remembers and continues from the end of the page.
  */
 @ThreadSafeAfterInit
 public class StatusListAssignmentStore extends AbstractIdentifiableInitializableComponent {
@@ -73,6 +76,10 @@ public class StatusListAssignmentStore extends AbstractIdentifiableInitializable
     @Nonnull
     private final Logger log = LoggerFactory.getLogger(StatusListAssignmentStore.class);
 
+    /** Lock storing one record at a time within this node. */
+    @Nonnull
+    private final Lock writeLock = new ReentrantLock();
+
     /** How long a record waits to be collected. */
     @Nonnull
     private Duration retention = Duration.ofDays(7);
@@ -144,7 +151,8 @@ public class StatusListAssignmentStore extends AbstractIdentifiableInitializable
     /**
      * Store a record of an assignment.
      *
-     * The record takes the next position of the sequence.
+     * The record takes the first free position from the sequence on, and the sequence
+     * then moves past it. One record is stored at a time within this node.
      *
      * @param record record to store
      * @throws IOException if the record cannot be serialized or stored, or if
@@ -155,22 +163,47 @@ public class StatusListAssignmentStore extends AbstractIdentifiableInitializable
         checkComponentActive();
         Constraint.isNotNull(record, "Record cannot be null");
 
-        final long seq = nextSequence();
-        record.setSeq(seq);
-
-        final String value;
+        writeLock.lock();
         try {
-            value = record.serialize();
-        } catch (final JsonProcessingException e) {
-            throw new IOException("Unable to serialize record of a status list assignment", e);
+            storeInSequence(record);
+        } finally {
+            writeLock.unlock();
         }
+    }
 
-        if (!storage.create(CONTEXT, Long.toString(seq), value, Instant.now().plus(retention).toEpochMilli())) {
-            throw new IOException("Record of a status list assignment was not stored at " + seq);
+    /**
+     * Store a record at the first free position from the sequence on and move the sequence past it.
+     *
+     * @param record record to store
+     * @throws IOException if the record cannot be serialized or stored, or if
+     *                     contention could not be resolved within
+     *                     {@value #MAX_RETRIES} attempts
+     */
+    private void storeInSequence(@Nonnull final StatusListAssignment record) throws IOException {
+        final long expiration = Instant.now().plus(retention).toEpochMilli();
+        long seq = currentSequence();
+        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+            record.setSeq(seq);
+            final String value;
+            try {
+                value = record.serialize();
+            } catch (final JsonProcessingException e) {
+                throw new IOException("Unable to serialize record of a status list assignment", e);
+            }
+
+            if (storage.create(CONTEXT, Long.toString(seq), value, expiration)) {
+                log.debug("Stored record {} of {} slot(s) of status list {}", seq,
+                        record.getIndices() == null ? 0 : record.getIndices().size(), record.getId());
+                advanceSequence(seq + 1);
+                return;
+            }
+
+            log.trace("Position {} of the record sequence taken, attempt {}/{}", seq, attempt + 1, MAX_RETRIES);
+            seq = Math.max(seq + 1, currentSequence());
         }
 
-        log.debug("Stored record {} of {} slot(s) of status list {}", seq,
-                record.getIndices() == null ? 0 : record.getIndices().size(), record.getId());
+        throw new IOException(
+                "Failed to take a record position after " + MAX_RETRIES + " attempts (storage contention).");
     }
 
     /**
@@ -226,36 +259,37 @@ public class StatusListAssignmentStore extends AbstractIdentifiableInitializable
     }
 
     /**
-     * Take the next position in the sequence.
+     * Move the sequence to a position, unless it is there or beyond already.
      *
-     * @return position taken
-     * @throws IOException if the storage backend errors, or if contention could not
-     *                     be resolved within {@value #MAX_RETRIES} attempts
+     * @param next position beyond a stored record
+     * @throws IOException if the storage backend errors
      */
-    private long nextSequence() throws IOException {
+    private void advanceSequence(final long next) throws IOException {
         for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
             final StorageRecord<String> current = storage.read(CONTEXT, KEY_SEQUENCE);
-            final long seq = current == null ? 0L : Long.parseLong(current.getValue());
-            final String updated = Long.toString(seq + 1);
-
             if (current == null) {
                 // The sequence outlives every record it numbers, so it is kept without
                 // expiration.
-                if (storage.create(CONTEXT, KEY_SEQUENCE, updated, null)) {
-                    return seq;
+                if (storage.create(CONTEXT, KEY_SEQUENCE, Long.toString(next), null)) {
+                    return;
                 }
-            } else {
-                try {
-                    storage.updateWithVersion(current.getVersion(), CONTEXT, KEY_SEQUENCE, updated, null);
-                    return seq;
-                } catch (final VersionMismatchException e) {
-                    log.trace("Version mismatch on the record sequence, attempt {}/{}", attempt + 1, MAX_RETRIES);
+                continue;
+            }
+            final long version = current.getVersion();
+            if (Long.parseLong(current.getValue()) >= next) {
+                return;
+            }
+            try {
+                if (storage.updateWithVersion(version, CONTEXT, KEY_SEQUENCE, Long.toString(next), null) != null) {
+                    return;
                 }
+            } catch (final VersionMismatchException e) {
+                log.trace("Version mismatch on the record sequence, attempt {}/{}", attempt + 1, MAX_RETRIES);
             }
         }
 
-        throw new IOException(
-                "Failed to take a record position after " + MAX_RETRIES + " attempts (storage contention).");
+        log.warn("Record sequence not moved to {} in {} attempts, the record before it becomes readable with "
+                + "the next record stored", next, MAX_RETRIES);
     }
 
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocator.java
index 2c13e44..69970b9 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocator.java
@@ -21,6 +21,8 @@ import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import javax.annotation.Nonnull;
 
@@ -61,6 +63,9 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
     /** Bounded retry cap for optimistic-update contention. */
     private static final int MAX_RETRIES = 32;
 
+    /** Number of locks the buckets are spread over. */
+    private static final int LOCKS = 64;
+
     /**
      * Permutation key shipped in the plugin's properties, to be replaced on
      * install.
@@ -86,6 +91,10 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
     @Nonnull
     private final Logger log = LoggerFactory.getLogger(StatusListIndexAllocator.class);
 
+    /** Locks serialising the allocations of a bucket within this node. */
+    @Nonnull
+    private final Lock[] locks = new Lock[LOCKS];
+
     /** Defensive ceiling on the number of slots one call may draw. */
     private int maxSlotsPerAllocation = 1024;
 
@@ -99,6 +108,13 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
     /** Backing storage for the allocator state. */
     private StorageService storage;
 
+    /** Constructor. */
+    public StatusListIndexAllocator() {
+        for (int i = 0; i < LOCKS; i++) {
+            locks[i] = new ReentrantLock();
+        }
+    }
+
     /**
      * Get the backing store for the allocator.
      *
@@ -193,8 +209,9 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
      * Allocate slots from a bucket.
      *
      * Advances the bucket's counter by {@code count} with an optimistic update,
-     * retrying on contention up to {@value #MAX_RETRIES} times, then maps the
-     * consumed counter values to indices.
+     * one allocation of a bucket at a time within this node, retrying on contention
+     * up to {@value #MAX_RETRIES} times, then maps the consumed counter values to
+     * indices.
      *
      * @param bucket bucket to draw from
      * @param count  number of slots needed
@@ -214,8 +231,32 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
         final String key = bucket.id();
         final Long expiration = bucket.end().plus(RETENTION).toEpochMilli();
 
+        final Lock lock = locks[Math.floorMod(key.hashCode(), LOCKS)];
+        lock.lock();
+        try {
+            return allocateInBucket(key, count, expiration);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    /**
+     * Advance a bucket's counter with an optimistic update and map the consumed counter values to indices.
+     *
+     * @param key        bucket identifier
+     * @param count      number of slots needed
+     * @param expiration when the bucket's state may be discarded
+     * @return newly allocated indices
+     * @throws IOException if the bucket is exhausted, if the storage backend
+     *                     errors, or if contention could not be resolved within
+     *                     {@value #MAX_RETRIES} attempts
+     */
+    @Nonnull
+    private List<Long> allocateInBucket(@Nonnull final String key, final int count, @Nonnull final Long expiration)
+            throws IOException {
         for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
             final StorageRecord<String> current = storage.read(CONTEXT, key);
+            final long version = current == null ? 0L : current.getVersion();
             final long allocated = current == null ? 0L : Long.parseLong(current.getValue());
 
             if (allocated + count > CAPACITY) {
@@ -229,7 +270,7 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
             if (current == null) {
                 committed = storage.create(CONTEXT, key, nextValue, expiration);
             } else {
-                committed = tryUpdate(current.getVersion(), key, nextValue, expiration);
+                committed = tryUpdate(version, key, nextValue, expiration);
             }
 
             if (committed) {
@@ -257,14 +298,13 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
      * @param value      new counter value
      * @param expiration when the bucket's state may be discarded
      * @return {@code true} if the update committed, {@code false} on version
-     *         mismatch
+     *         mismatch or if the record no longer exists
      * @throws IOException on any other storage error
      */
     private boolean tryUpdate(final long version, @Nonnull final String key, @Nonnull final String value,
             @Nonnull final Long expiration) throws IOException {
         try {
-            storage.updateWithVersion(version, CONTEXT, key, value, expiration);
-            return true;
+            return storage.updateWithVersion(version, CONTEXT, key, value, expiration) != null;
         } catch (final VersionMismatchException e) {
             return false;
         }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStore.java
index 9d6fb4c..b51caeb 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStore.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStore.java
@@ -17,6 +17,9 @@
 package org.geant.shibboleth.plugin.openidvci.statuslist.storage;
 
 import java.io.IOException;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -58,13 +61,27 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
     /** Bounded retry cap for optimistic-update contention. */
     private static final int MAX_RETRIES = 32;
 
+    /** Number of locks the pieces are spread over. */
+    private static final int LOCKS = 64;
+
     /** Logger. */
     @Nonnull
     private final Logger log = LoggerFactory.getLogger(StatusListStore.class);
 
+    /** Locks serialising the updates of a piece within this node. */
+    @Nonnull
+    private final Lock[] locks = new Lock[LOCKS];
+
     /** Backing storage for the lists. */
     private StorageService storage;
 
+    /** Constructor. */
+    public StatusListStore() {
+        for (int i = 0; i < LOCKS; i++) {
+            locks[i] = new ReentrantLock();
+        }
+    }
+
     /**
      * Get the backing store.
      *
@@ -165,7 +182,9 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
      * Revoke a slot of a list.
      *
      * Reads the piece of the slot, sets the slot and commits with an optimistic
-     * update. Retries on contention {@value #MAX_RETRIES} times. The piece is
+     * update, one update of a piece at a time within this node. Retries on
+     * contention {@value #MAX_RETRIES} times, each after a random wait growing
+     * with the attempts. The piece is
      * created if it does not exist. Revoking an already revoked slot does nothing.
      *
      * @param bucket bucket of the list
@@ -185,8 +204,32 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
         final String key = key(bucket, chunk);
         final Long expiration = bucket.end().plus(StatusListParameters.RETENTION).toEpochMilli();
 
+        final Lock lock = locks[Math.floorMod(key.hashCode(), LOCKS)];
+        lock.lock();
+        try {
+            revokeInPiece(bucket, index, key, offset, expiration);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    /**
+     * Revoke a slot in its piece with an optimistic update.
+     *
+     * @param bucket     bucket of the list
+     * @param index      slot to revoke
+     * @param key        key of the piece
+     * @param offset     slot within the piece
+     * @param expiration when the piece may be discarded
+     * @throws IOException if the stored piece cannot be read, the storage backend
+     *                     fails or contention was not resolved in
+     *                     {@value #MAX_RETRIES} attempts
+     */
+    private void revokeInPiece(@Nonnull final StatusListBucket bucket, final long index, @Nonnull final String key,
+            final long offset, @Nonnull final Long expiration) throws IOException {
         for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
             final StorageRecord<String> current = storage.read(CONTEXT, key);
+            final long version = current == null ? 0L : current.getVersion();
 
             final byte[] piece = current == null ? StatusListCodec.emptyChunk()
                     : StatusListCodec.decodeChunk(current.getValue());
@@ -203,7 +246,7 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
             if (current == null) {
                 committed = storage.create(CONTEXT, key, updated, expiration);
             } else {
-                committed = tryUpdate(current.getVersion(), key, updated, expiration);
+                committed = tryUpdate(version, key, updated, expiration);
             }
 
             if (committed) {
@@ -212,12 +255,28 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
             }
 
             log.trace("Version mismatch on status list piece {}, attempt {}/{}", key, attempt + 1, MAX_RETRIES);
+            backOff(attempt);
         }
 
         throw new IOException("Failed to revoke slot " + index + " of status list " + bucket.id() + " after "
                 + MAX_RETRIES + " attempts (storage contention).");
     }
 
+    /**
+     * Wait a random time of up to {@code attempt + 1} milliseconds before the next attempt.
+     *
+     * @param attempt attempt that failed, from 0
+     * @throws IOException if interrupted while waiting
+     */
+    private static void backOff(final int attempt) throws IOException {
+        try {
+            Thread.sleep(ThreadLocalRandom.current().nextLong(attempt + 2L));
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IOException("Interrupted while waiting to retry a revocation", e);
+        }
+    }
+
     /**
      * Update of a list, returning {@code false} on version mismatch.
      *
@@ -226,14 +285,13 @@ public class StatusListStore extends AbstractIdentifiableInitializableComponent
      * @param value      new encoded list
      * @param expiration when the list may be discarded
      * @return {@code true} if the update committed, {@code false} on version
-     *         mismatch
+     *         mismatch or if the record no longer exists
      * @throws IOException on any other storage error
      */
     private boolean tryUpdate(final long version, @Nonnull final String key, @Nonnull final String value,
             @Nonnull final Long expiration) throws IOException {
         try {
-            storage.updateWithVersion(version, CONTEXT, key, value, expiration);
-            return true;
+            return storage.updateWithVersion(version, CONTEXT, key, value, expiration) != null;
         } catch (final VersionMismatchException e) {
             return false;
         }
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index ee748a4..5ae7ed8 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -86,6 +86,10 @@
     <bean id="openidvci.ClientInstanceKeyThumbprintLookup"
         class="org.geant.shibboleth.plugin.openidvci.profile.context.navigate.ClientInstanceKeyThumbprintLookupFunction"
         c:supplier-ref="shibboleth.HttpServletRequestSupplier" />
+
+    <bean id="openidvci.AllowedCredentialConfigurationsLookup" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.profile.logic.AllowedCredentialConfigurationsLookupFunction"
+        c:resolver-ref="openidvci.CredentialConfigurationsResolver" />
     
 
     <!--
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
index 29afba5..f91e5ab 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
@@ -40,7 +40,8 @@
   <bean id="OpenIDVCIFormOutboundMessage"
         class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.FormOutboundStatusListAssignmentsResponseMessage"
         scope="prototype"
-        p:statusListAssignmentStore-ref="openidvci.status-list.AssignmentStore" />
+        p:statusListAssignmentStore-ref="openidvci.status-list.AssignmentStore"
+        p:allowedCredentialConfigurationsLookupStrategy-ref="openidvci.AllowedCredentialConfigurationsLookup" />
 
   <bean id="OpenIDVCIBuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
         scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
index cf060cf..7740aec 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
@@ -27,6 +27,9 @@
     
  <bean id="ValidateCredentialOffering" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateCredentialOffering"
      scope="prototype" p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver"/>
+
+ <bean id="ValidateCredentialOfferScope" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateCredentialOfferScope"
+     scope="prototype" p:allowedCredentialConfigurationsLookupStrategy-ref="openidvci.AllowedCredentialConfigurationsLookup"/>
      
  <bean id="BuildCredentialOfferToken" class="org.geant.shibboleth.plugin.openidvci.profile.impl.BuildCredentialOfferToken"
     p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple"
@@ -47,6 +50,7 @@
   <property name="mappedErrors">
       <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
         <entry key="#{T(org.opensaml.profile.action.EventIds).ACCESS_DENIED}" value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_CLIENT}" />
+        <entry key="#{T(net.shibboleth.oidc.profile.core.OidcEventIds).INVALID_SCOPE}" value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_SCOPE}" />
         <entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).NO_CREDENTIAL_REQUEST}" value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).CONF_ERROR}" />
         <entry key="#{T(org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds).NO_CREDENTIAL_CONFIGURATION}" value="#{T(org.geant.shibboleth.plugin.openidvci.messaging.error.OpenIDVCIError).CONF_ERROR}" />
       </map>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
index c456a4d..1677b17 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
@@ -17,6 +17,7 @@
 
   <action-state id="ResumeAfterAuthentication">
     <evaluate expression="ValidateCredentialOffering"/>
+    <evaluate expression="ValidateCredentialOfferScope"/>
     <evaluate expression="BuildCredentialOfferToken"/>
     <evaluate expression="'proceed'"/>
     <transition on="proceed" to="BuildResponseMessage"/>
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScopeTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScopeTest.java
new file mode 100644
index 0000000..f0b15f3
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferScopeTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialOfferContext;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.Event;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.oidc.profile.core.OidcEventIds;
+
+/**
+ * Unit tests for {@link ValidateCredentialOfferScope}.
+ */
+public class ValidateCredentialOfferScopeTest {
+
+    private RequestContext requestCtx;
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private ValidateCredentialOfferScope action;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setInboundMessageContext(new MessageContext());
+        final CredentialOfferContext ctx = new CredentialOfferContext();
+        ctx.setValidatedPreAuthorizedCredentials(Map.of("ScopedCredential", List.of()));
+        profileRequestCtx.getInboundMessageContext().addSubcontext(ctx);
+        action = new ValidateCredentialOfferScope();
+    }
+
+    @Test
+    public void testNoScope() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> null);
+        action.initialize();
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertProceedEvent(event);
+    }
+
+    @Test
+    public void testInScope() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> Set.of("ScopedCredential"));
+        action.initialize();
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertProceedEvent(event);
+    }
+
+    @Test
+    public void testNotInScope() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> Set.of("OtherCredential"));
+        action.initialize();
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertEvent(event, OidcEventIds.INVALID_SCOPE);
+    }
+
+    @Test
+    public void testNoneInScope() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> Set.of());
+        action.initialize();
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertEvent(event, OidcEventIds.INVALID_SCOPE);
+    }
+
+    @Test
+    public void testNoContext() throws Exception {
+        profileRequestCtx.getInboundMessageContext().removeSubcontext(CredentialOfferContext.class);
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> null);
+        action.initialize();
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertEvent(event, EventIds.INVALID_PROFILE_CTX);
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunctionTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunctionTest.java
new file mode 100644
index 0000000..5313c98
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/AllowedCredentialConfigurationsLookupFunctionTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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.logic;
+
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.Scope;
+
+/**
+ * Unit tests for {@link AllowedCredentialConfigurationsLookupFunction}.
+ */
+public class AllowedCredentialConfigurationsLookupFunctionTest {
+
+    private AllowedCredentialConfigurationsLookupFunction function;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+                new ClassPathResource("conf/verifiable-credentials-scoped.json"));
+        resolver.setId("test");
+        resolver.initialize();
+        function = new AllowedCredentialConfigurationsLookupFunction(resolver);
+    }
+
+    @Test
+    public void testNoMetadata() {
+        Assert.assertNull(function.apply(new ProfileRequestContext()));
+    }
+
+    @Test
+    public void testNoScope() {
+        function.setAllowedScopeLookupStrategy(prc -> null);
+        Assert.assertNull(function.apply(new ProfileRequestContext()));
+    }
+
+    @Test
+    public void testEmptyScope() {
+        function.setAllowedScopeLookupStrategy(prc -> new Scope());
+        Assert.assertNull(function.apply(new ProfileRequestContext()));
+    }
+
+    @Test
+    public void testScope() {
+        function.setAllowedScopeLookupStrategy(prc -> new Scope("openid", "ScopedCredential"));
+        Assert.assertEquals(function.apply(new ProfileRequestContext()), Set.of("ScopedCredential"));
+    }
+
+    @Test
+    public void testScopeOfNoConfiguration() {
+        function.setAllowedScopeLookupStrategy(prc -> new Scope("UnscopedCredential"));
+        Assert.assertEquals(function.apply(new ProfileRequestContext()), Set.of());
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessageTest.java
new file mode 100644
index 0000000..790bdf1
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessageTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.statuslist.profile.impl;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListAssignmentsRequest;
+import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListAssignmentsSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignment;
+import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignmentStore;
+import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListAssignments;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.springframework.webflow.execution.Event;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+
+/**
+ * Unit tests for {@link FormOutboundStatusListAssignmentsResponseMessage}.
+ */
+public class FormOutboundStatusListAssignmentsResponseMessageTest {
+
+    private MemoryStorageService storageService;
+
+    private StatusListAssignmentStore store;
+
+    private RequestContext requestCtx;
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private FormOutboundStatusListAssignmentsResponseMessage action;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+        store = new StatusListAssignmentStore();
+        store.setId("test");
+        store.setStorage(storageService);
+        store.initialize();
+        store.store(record("ScopedCredential", 1L));
+        store.store(record("OtherCredential", 2L));
+        store.store(record("ScopedCredential", 3L));
+
+        final HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.POST, new URI("http://example.com"));
+        httpRequest.setAuthorization("Basic dGVzdDp0ZXN0");
+        httpRequest.setContentType("application/json");
+        httpRequest.setBody("{\"from\": 0, \"limit\": 100}");
+
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setInboundMessageContext(new MessageContext());
+        profileRequestCtx.getInboundMessageContext().setMessage(StatusListAssignmentsRequest.parse(httpRequest));
+
+        action = new FormOutboundStatusListAssignmentsResponseMessage();
+        action.setStatusListAssignmentStore(store);
+    }
+
+    @AfterMethod
+    protected void tearDown() {
+        store.destroy();
+        storageService.destroy();
+    }
+
+    private StatusListAssignment record(final String configurationId, final long index) {
+        final StatusListAssignment assignment = new StatusListAssignment();
+        assignment.setId("2027-03");
+        assignment.setIndices(List.of(index));
+        assignment.setExpires(1802769445L);
+        assignment.setDetails(Map.of("credential_configuration_id", configurationId));
+        return assignment;
+    }
+
+    private StatusListAssignments execute() {
+        final Event event = action.execute(requestCtx);
+        ActionTestingSupport.assertProceedEvent(event);
+        return ((StatusListAssignmentsSuccessResponse) profileRequestCtx.getOutboundMessageContext().getMessage())
+                .getRecords();
+    }
+
+    @Test
+    public void testNotRestricted() throws Exception {
+        action.initialize();
+        final StatusListAssignments records = execute();
+        Assert.assertEquals(records.getRecords().size(), 3);
+        Assert.assertEquals(records.getNext(), 3L);
+        Assert.assertEquals(records.getHighWater(), 3L);
+    }
+
+    @Test
+    public void testRestricted() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> Set.of("ScopedCredential"));
+        action.initialize();
+        final StatusListAssignments records = execute();
+        Assert.assertEquals(records.getRecords().size(), 2);
+        Assert.assertEquals(records.getRecords().get(0).getIndices(), List.of(1L));
+        Assert.assertEquals(records.getRecords().get(1).getIndices(), List.of(3L));
+        Assert.assertEquals(records.getNext(), 3L);
+        Assert.assertEquals(records.getHighWater(), 3L);
+    }
+
+    @Test
+    public void testNoneAllowed() throws Exception {
+        action.setAllowedCredentialConfigurationsLookupStrategy(prc -> Set.of());
+        action.initialize();
+        final StatusListAssignments records = execute();
+        Assert.assertTrue(records.getRecords().isEmpty());
+        Assert.assertEquals(records.getNext(), 3L);
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStoreTest.java
index 302566f..6a44197 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStoreTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListAssignmentStoreTest.java
@@ -19,8 +19,14 @@ package org.geant.shibboleth.plugin.openidvci.statuslist.storage;
 import java.io.IOException;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 
 import org.opensaml.storage.StorageRecord;
 import org.opensaml.storage.impl.MemoryStorageService;
@@ -230,4 +236,46 @@ public class StatusListAssignmentStoreTest {
         }
     }
 
+    @Test
+    public void testReadingAlongParallelWritesMissesNoRecord() throws Exception {
+        final int records = 2000;
+        final ExecutorService pool = Executors.newFixedThreadPool(8);
+        final List<Long> read = new ArrayList<>();
+        try {
+            final List<Future<?>> writes = new ArrayList<>();
+            for (int i = 0; i < records; i++) {
+                final long index = i;
+                writes.add(pool.submit(() -> {
+                    store.store(record("2027-02", List.of(index)));
+                    return null;
+                }));
+            }
+
+            long next = 0;
+            while (!writes.stream().allMatch(Future::isDone)) {
+                final StatusListAssignments page = store.read(next, 100);
+                page.getRecords().forEach(r -> read.add(r.getSeq()));
+                next = page.getNext();
+            }
+            for (final Future<?> write : writes) {
+                write.get();
+            }
+            StatusListAssignments page;
+            do {
+                page = store.read(next, 100);
+                page.getRecords().forEach(r -> read.add(r.getSeq()));
+                next = page.getNext();
+            } while (next < page.getHighWater());
+        } finally {
+            pool.shutdown();
+        }
+
+        final Set<Long> unique = new HashSet<>(read);
+        Assert.assertEquals(read.size(), unique.size(), "A record was read twice");
+        Assert.assertEquals(unique.size(), records, "Reading along the writes missed a record");
+        for (long seq = 0; seq < records; seq++) {
+            Assert.assertTrue(unique.contains(seq), "Position " + seq + " was not read");
+        }
+    }
+
 }
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocatorTest.java
index 69a5493..ad09df3 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocatorTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListIndexAllocatorTest.java
@@ -19,9 +19,13 @@ package org.geant.shibboleth.plugin.openidvci.statuslist.storage;
 import java.io.IOException;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 
 import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListBucket;
 import org.opensaml.storage.StorageRecord;
@@ -207,4 +211,27 @@ public class StatusListIndexAllocatorTest {
         Assert.assertFalse(seen.contains(next.get(0)), "Restart must not hand out an already allocated slot");
     }
 
+    @Test
+    public void testParallelAllocationsGetDistinctSlots() throws Exception {
+        final int allocations = 2000;
+        final ExecutorService pool = Executors.newFixedThreadPool(8);
+        final List<Long> slots = new ArrayList<>();
+        try {
+            final List<Future<List<Long>>> results = new ArrayList<>();
+            for (int i = 0; i < allocations; i++) {
+                results.add(pool.submit(() -> allocator.allocate(BUCKET, 1)));
+            }
+            for (final Future<List<Long>> result : results) {
+                slots.addAll(result.get());
+            }
+        } finally {
+            pool.shutdown();
+        }
+
+        Assert.assertEquals(slots.size(), allocations);
+        Assert.assertEquals(new HashSet<>(slots).size(), allocations, "A slot was given to two credentials");
+        Assert.assertEquals(storageService.read(StatusListIndexAllocator.class.getName(), BUCKET.id()).getValue(),
+                Long.toString(allocations));
+    }
+
 }
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStoreTest.java
index 3f77d8f..6be5389 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStoreTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/storage/StatusListStoreTest.java
@@ -20,6 +20,11 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.zip.Inflater;
 
 import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListBucket;
@@ -196,4 +201,31 @@ public class StatusListStoreTest {
         Assert.assertTrue(StatusListParameters.RETENTION.compareTo(Duration.ZERO) > 0);
     }
 
+    @Test
+    public void testParallelRevocationsOfOnePieceAreAllKept() throws Exception {
+        final int slots = 2000;
+        final ExecutorService pool = Executors.newFixedThreadPool(8);
+        try {
+            final List<Future<?>> revocations = new ArrayList<>();
+            for (int i = 0; i < slots; i++) {
+                final long index = i * 7L;
+                revocations.add(pool.submit(() -> {
+                    store.revoke(BUCKET, index);
+                    return null;
+                }));
+            }
+            for (final Future<?> revocation : revocations) {
+                revocation.get();
+            }
+        } finally {
+            pool.shutdown();
+        }
+
+        final byte[] list = published(store.read(BUCKET));
+        for (int i = 0; i < slots; i++) {
+            Assert.assertFalse(StatusListCodec.isValid(list, i * 7L), "Revocation of slot " + i * 7L + " was lost");
+        }
+        Assert.assertTrue(StatusListCodec.isValid(list, 1));
+    }
+
 }

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


More information about the commits mailing list