[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