[java-idp-plugin-vci] 02/02: IETF Status List entry creation and using it in VC

Codeberg noreply at shibboleth.net
Wed Aug 5 11:11:48 UTC 2026


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

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

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

commit 4dfffd062a2fed7ebe644aa4cff07e14cd8c1170
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Aug 5 14:11:22 2026 +0300

    IETF Status List entry creation and using it in VC
---
 .../plugin/oauth/statuslist/StatusListIndex.java   |  33 +++
 .../statuslist/context/StatusListContext.java      |  72 +++++++
 .../statuslist/profile/impl/AllocateIndex.java     | 168 ++++++++++++++++
 .../storage/StatusListIndexAllocator.java          | 204 +++++++++++++++++++
 .../openidvci/profile/impl/AddCredentialShell.java |  20 +-
 .../oauth/status-list/assign/assign-beans.xml      |  25 +++
 .../flows/oauth/status-list/assign/assign-flow.xml |  26 +++
 .../openid/vci/credentials/credentials-flow.xml    |  40 ++--
 .../statuslist/profile/impl/AllocateIndexTest.java | 221 +++++++++++++++++++++
 .../storage/StatusListIndexAllocatorTest.java      | 136 +++++++++++++
 10 files changed, 923 insertions(+), 22 deletions(-)

diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java
new file mode 100644
index 0000000..831855a
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java
@@ -0,0 +1,33 @@
+/*
+ * 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.oauth.statuslist;
+
+/**
+ * Immutable value identifying a single slot in a Status List, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ *
+ * {@code uriIndex} identifies which list-generation the slot belongs to; the
+ * mapping from {@code uriIndex} to the actual {@code status_list.uri} embedded
+ * in issued credentials is the responsibility of the caller.
+ *
+ * @param index    slot within the list (0-based, less than the list's
+ *                 configured capacity)
+ * @param uriIndex generation of the list this slot belongs to (0-based,
+ *                 incremented on rollover)
+ */
+public record StatusListIndex(long index, long uriIndex) { }
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java
new file mode 100644
index 0000000..c97f1a9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java
@@ -0,0 +1,72 @@
+/*
+ * 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.oauth.statuslist.context;
+
+import org.opensaml.messaging.context.BaseContext;
+
+/**
+ * Subcontext for carrying allocated index and path component it's status list
+ * url as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>. This context appears as a subcontext of the
+ * outbound {@link MessageContext}.
+ */
+public class StatusListContext extends BaseContext {
+
+    /** Allocated status list index. */
+    private long index;
+
+    /** Path component of allocated status list url for allocated index. */
+    private String statusListUrlPath;
+
+    /**
+     * Get allocated status list index.
+     * 
+     * @return Allocated status list index
+     */
+    public long getIndex() {
+        return index;
+    }
+
+    /**
+     * Set allocated status list index.
+     * 
+     * @param index Allocated status list index
+     */
+    public void setIndex(long index) {
+        this.index = index;
+    }
+
+    /**
+     * Get Path component of allocated status list url for allocated index.
+     * 
+     * @return path component of allocated status list url for allocated index
+     */
+    public String getStatusListUrlPath() {
+        return statusListUrlPath;
+    }
+
+    /**
+     * Set path component of allocated status list url for allocated index.
+     * 
+     * @param statusListUrlPath Path component of allocated status list url for
+     *                          allocated index
+     */
+    public void setStatusListUrlPath(String statusListUrlPath) {
+        this.statusListUrlPath = statusListUrlPath;
+    }
+}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java
new file mode 100644
index 0000000..94bc317
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java
@@ -0,0 +1,168 @@
+/*
+ * 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.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that allocates a status list slot via {@link StatusListIndexAllocator}
+ * and populates a {@link StatusListContext} on the outbound
+ * {@link MessageContext}.
+ */
+public class AllocateIndex extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(AllocateIndex.class);
+
+    /** Allocator for status list slots. */
+    @NonnullAfterInit
+    private StatusListIndexAllocator statusListAllocator;
+
+    /**
+     * Path prefixing etc is still a WIP. Minimal implementention to move forward.
+     **/
+    /**
+     * Path prefix prepended to the {@code uriIndex} to form the status list URL
+     * path.
+     */
+    @Nonnull
+    private String pathPrefix = "/idp/profile/statusList";
+
+    /**
+     * Set the status list index allocator.
+     *
+     * @param allocator The allocator to use.
+     */
+    public void setStatusListAllocator(@Nonnull final StatusListIndexAllocator allocator) {
+        checkSetterPreconditions();
+        statusListAllocator = Constraint.isNotNull(allocator, "StatusListIndexAllocator cannot be null");
+    }
+
+    /**
+     * Set the path prefix prepended to the allocated {@code uriIndex}.
+     *
+     * @param prefix Path prefix to use.
+     */
+    public void setPathPrefix(@Nonnull final String prefix) {
+        checkSetterPreconditions();
+        pathPrefix = Constraint.isNotNull(prefix, "Path prefix cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (statusListAllocator == null) {
+            throw new ComponentInitializationException("StatusListIndexAllocator cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        if (getHttpServletRequest() == null) {
+            log.error("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final MessageContext outbound = profileRequestContext.getOutboundMessageContext();
+        if (outbound == null) {
+            log.error("{} No outbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+
+        final StatusListIndex slot;
+        try {
+            slot = statusListAllocator.allocate();
+        } catch (final IOException e) {
+            log.error("{} Failed to allocate status list slot: {}", getLogPrefix(), e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return;
+        }
+
+        final URI statusListUri;
+        try {
+            final HttpServletRequest request = getHttpServletRequest();
+            final String scheme = request.getScheme();
+            assert scheme != null;
+            final String serverName = request.getServerName();
+            assert serverName != null;
+            statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(),
+                    pathPrefix + "/" + slot.uriIndex());
+        } catch (final URISyntaxException e) {
+            log.error("{} Unable to build status list URI", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+
+        final StatusListContext ctx = outbound.ensureSubcontext(StatusListContext.class);
+        ctx.setIndex(slot.index());
+        ctx.setStatusListUrlPath(statusListUri.toString());
+
+        log.debug("{} Allocated status list slot uriIndex={} index={} uri={}", getLogPrefix(), slot.uriIndex(),
+                slot.index(), statusListUri);
+    }
+
+    /**
+     * Build a URI, omitting the port when it is the default for the scheme
+     * ({@code 443} for {@code https}, {@code 80} for {@code http}).
+     *
+     * @param scheme URI scheme
+     * @param host   host name
+     * @param port   server port
+     * @param path   path component
+     * @return constructed URI
+     * @throws URISyntaxException if the components do not form a valid URI
+     */
+    @Nonnull
+    private URI buildURIIgnoreDefaultPorts(@Nonnull final String scheme, @Nonnull final String host, final int port,
+            @Nonnull final String path) throws URISyntaxException {
+        final boolean defaultPort = ("https".equals(scheme) && port == 443) || ("http".equals(scheme) && port == 80);
+        return new URI(scheme, null, host, defaultPort ? -1 : port, path, null, null);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java
new file mode 100644
index 0000000..47cb853
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java
@@ -0,0 +1,204 @@
+/*
+ * 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.oauth.statuslist.storage;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.opensaml.storage.StorageCapabilities;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.opensaml.storage.VersionMismatchException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Allocator producing increasing {@link StatusListIndex} values, backed by
+ * Shibboleth {@link StorageService}.
+ *
+ * Records use no expiration. Memory-backed StorageService is not
+ * suitable for real deployment.
+ */
+ at ThreadSafeAfterInit
+public class StatusListIndexAllocator extends AbstractIdentifiableInitializableComponent {
+
+    /** Logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(StatusListIndexAllocator.class);
+
+    /** Storage context for allocator state. */
+    @Nonnull
+    @NotEmpty
+    private static final String CONTEXT = StatusListIndexAllocator.class.getName();
+
+    /** Storage key for the packed counter. */
+    @Nonnull
+    @NotEmpty
+    private static final String KEY_COUNTER = "counter";
+
+    /** Delimeter between {@code uriIndex} and {@code index} in the packed value. */
+    @Nonnull
+    @NotEmpty
+    private static final String DEL = ":";
+
+    /** Bounded retry cap for optimistic-update contention. */
+    private static final int  = 32;
+
+    /** Max slots per list before rolling to the next {@code uriIndex}. */
+    private int maxIndex = 500_000;
+
+    /** Backing storage for the allocator state. */
+    private StorageService storage;
+
+    /**
+     * Get the backing store for the allocator.
+     *
+     * @return the backing store
+     */
+    @NonnullAfterInit
+    public StorageService getStorage() {
+        return storage;
+    }
+
+    /**
+     * Set the backing store.
+     *
+     * @param storageService backing store to use
+     */
+    public void setStorage(@Nonnull final StorageService storageService) {
+        checkSetterPreconditions();
+
+        storage = Constraint.isNotNull(storageService, "StorageService cannot be null");
+        final StorageCapabilities caps = storage.getCapabilities();
+        Constraint.isTrue(caps.isServerSide(), "StorageService cannot be client-side");
+        Constraint.isTrue(CONTEXT.length() <= caps.getContextSize(),
+                "Context " + CONTEXT.length() + " too long for StorageService " + caps.getContextSize());
+    }
+
+    /**
+     * Get the maximum number of slots per list before rollover.
+     *
+     * @return max slots per list
+     */
+    public int getMaxIndex() {
+        return maxIndex;
+    }
+
+    /**
+     * Set the maximum number of slots per list before rollover.
+     *
+     * @param max positive maximum
+     */
+    public void setMaxIndex(final int max) {
+        checkSetterPreconditions();
+        Constraint.isGreaterThan(999, max, "maxIndex must be at least 1000");
+        maxIndex = max;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void doInitialize() throws ComponentInitializationException {
+        if (storage == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        }
+    }
+
+    /**
+     * Allocate the next slot.
+     *
+     * Reads the current counter, computes the next {@code (uriIndex, index)}, and
+     * commits it with an optimistic update. Retries on contention up to
+     * {@value #MAX_RETRIES} times.
+     *
+     * @return newly allocated slot
+     * @throws IOException if the storage backend errors, or if contention could not
+     *                     be resolved within {@value #MAX_RETRIES} attempts
+     */
+    @Nonnull
+    public StatusListIndex allocate() throws IOException {
+        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+            final StorageRecord<String> current = storage.read(CONTEXT, KEY_COUNTER);
+
+            final long uriIndex;
+            final int index;
+            if (current == null) {
+                uriIndex = 0L;
+                index = -1;
+            } else {
+                final String[] parts = current.getValue().split(DEL, 2);
+                uriIndex = Long.parseLong(parts[0]);
+                index = Integer.parseInt(parts[1]);
+            }
+
+            int nextIndex = index + 1;
+            long nextUriIndex = uriIndex;
+            if (nextIndex >= maxIndex) {
+                nextIndex = 0;
+                nextUriIndex = uriIndex + 1L;
+            }
+
+            final String nextValue = nextUriIndex + DEL + nextIndex;
+
+            final boolean committed;
+            if (current == null) {
+                committed = storage.create(CONTEXT, KEY_COUNTER, nextValue, null);
+            } else {
+                committed = tryUpdate(current.getVersion(), nextValue);
+            }
+
+            if (committed) {
+                log.debug("Allocated status list slot uriIndex={} index={}", nextUriIndex, nextIndex);
+                return new StatusListIndex(nextIndex, nextUriIndex);
+            }
+
+            log.trace("Version mismatch on status list counter, attempt {}/{}", attempt + 1, MAX_RETRIES);
+        }
+
+        throw new IOException(
+                "Failed to allocate status list slot after " + MAX_RETRIES + " attempts (storage contention).");
+    }
+
+    /**
+     * Update of the counter, returning {@code false} on version
+     * mismatch.
+     *
+     * @param version expected version
+     * @param value   new packed value
+     * @return {@code true} if the update committed, {@code false} on version
+     *         mismatch
+     * @throws IOException on any other storage error
+     */
+    private boolean tryUpdate(final long version, @Nonnull final String value) throws IOException {
+        try {
+            // null expiration = never expires; see class javadoc.
+            storage.updateWithVersion(version, CONTEXT, KEY_COUNTER, value, null);
+            return true;
+        } catch (final VersionMismatchException e) {
+            return false;
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
index 25bfc78..8b6f0d9 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
@@ -15,14 +15,11 @@
  */
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
-import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.time.Duration;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Base64;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
@@ -40,6 +37,7 @@ import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
 
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
 import org.geant.shibboleth.plugin.openidvci.config.OpenIDVCIConfiguration;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
@@ -48,7 +46,6 @@ import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
-import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.oauth2.sdk.id.Issuer;
 import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
@@ -98,6 +95,10 @@ public class AddCredentialShell extends AbstractProfileAction {
     @NonnullAfterInit
     private CredentialsContext ctx;
 
+    /** Status list context if allocated for this issuance. Optional. */
+    @Nullable
+    private StatusListContext statusListContext;
+
     /** Constructor. */
     public AddCredentialShell() {
         relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
@@ -181,6 +182,11 @@ public class AddCredentialShell extends AbstractProfileAction {
             return false;
         }
 
+        if (profileRequestContext.getOutboundMessageContext() != null) {
+            statusListContext = profileRequestContext.getOutboundMessageContext()
+                    .getSubcontext(StatusListContext.class);
+        }
+
         return true;
     }
 
@@ -251,6 +257,12 @@ public class AddCredentialShell extends AbstractProfileAction {
             shell.setClaim("validUntil",
                     DateTimeFormatter.ISO_INSTANT.format(now.plusSeconds(expiration.getSeconds())));
         }
+        if (statusListContext != null && statusListContext.getStatusListUrlPath() != null) {
+            // TODO: batch issuance (multiple proofs) currently gives every shell
+            // the SAME (idx, uri). Each shell must receive its own allocated slot.
+            shell.setClaim("status", Map.of("status_list", Map.of("idx", statusListContext.getIndex(), "uri",
+                    statusListContext.getStatusListUrlPath())));
+        }
         return shell;
     }
 
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
new file mode 100644
index 0000000..edab3aa
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="oauth.StatusListIndexAllocator"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator"
+        depends-on="shibboleth.LoggingService"
+        p:storage-ref="#{'%{oauth.StatusListIndexAllocator:shibboleth.StorageService}'.trim()}" />
+
+  <bean id="AllocateIndex"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.AllocateIndex"
+        scope="prototype"
+        p:statusListAllocator-ref="oauth.StatusListIndexAllocator"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-flow.xml
new file mode 100644
index 0000000..316de88
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-flow.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+    abstract="true">
+
+    <!-- 1. creates status list context if status list is used --> 
+    <!-- 2. Allocates index from storage --> 
+    <!-- 3. Sets allocated index index to status list context --> 
+    <action-state id="AllocateStatusListIndex">
+        <evaluate expression="AllocateIndex" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="ResumeAfterAllocateStatusListIndex" />
+    </action-state>
+    
+    <!-- 1. Reads  index and credential metadata (string) from status list context--> 
+    <!-- 2. Stores index and credential metadata to storage or uses remote API --> 
+    <action-state id="AssignStatusListIndex">
+        <evaluate expression="AssignIndex" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="ResumeAfterAssignStatusListIndex" />
+    </action-state>
+
+    <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
index 384c97e..fad314f 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
@@ -1,16 +1,16 @@
 <?xml version="1.0"?>
-<flow xmlns="http://www.springframework.org/schema/webflow" 
-      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
-      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd" 
-      parent="openid/vci/abstract-api">
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api, oauth/status-list/assign">
 
   <action-state id="InitializeMandatoryContexts">
-    <evaluate expression="InitializeProfileRequestContext"/>
-    <evaluate expression="PopulateMetricContext"/>
-    <evaluate expression="FlowStartPopulateAuditContext"/>
-    <evaluate expression="InitializeOutboundMessageContext"/>
-    <evaluate expression="'proceed'"/>
-    
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="'proceed'" />
+
     <transition on="proceed" to="DecodeMessage">
       <set name="flowScope.transitionAfterDecode" value="'PostDecodeMessage'" />
       <set name="flowScope.skipOAuth2ClientAuth" value="true" />
@@ -21,7 +21,7 @@
     <evaluate expression="ParseAccessToken" />
     <evaluate expression="ValidateRequestedCredential" />
     <evaluate expression="'proceed'" />
-        
+
     <transition on="proceed" to="DoMetadataLookup">
       <set name="flowScope.skipOAuth2ClientAuth" value="true" />
     </transition>
@@ -32,20 +32,24 @@
     <evaluate expression="PopulateProofSignatureValidationParameters" />
     <evaluate expression="ValidateProofSignature" />
     <evaluate expression="PopulateCredentialsSignatureSigningParameters" />
-    <evaluate expression="'proceed'"/>
-    <transition on="proceed" to="BuildResponse"/>
+    <evaluate expression="'proceed'" />
+    <transition on="proceed" to="AllocateStatusListIndex" />
   </action-state>
-  
-  
+
+  <action-state id="ResumeAfterAllocateStatusListIndex">
+    <evaluate expression="'proceed'" />
+    <transition on="proceed" to="BuildResponse" />
+  </action-state>
+
   <action-state id="BuildResponse">
     <evaluate expression="AddCredentialShell" />
     <evaluate expression="OptionallyFormSelectiveDisclosureJWTCredential" />
     <evaluate expression="OptionallyFormJsonLdSelectiveDisclosureJWTCredential" />
     <evaluate expression="SignJWTCredential" />
-    <evaluate expression="'proceed'"/>
-    <transition on="proceed" to="BuildResponseMessage"/>
+    <evaluate expression="'proceed'" />
+    <transition on="proceed" to="BuildResponseMessage" />
   </action-state>
 
-  <bean-import resource="credentials-beans.xml"/>
+  <bean-import resource="credentials-beans.xml" />
 
 </flow>
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java
new file mode 100644
index 0000000..4d20437
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.springframework.mock.web.MockHttpServletRequest;
+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 jakarta.servlet.http.HttpServletRequest;
+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.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+
+/**
+ * Unit tests for {@link AllocateIndex}.
+ */
+public class AllocateIndexTest {
+
+    private static final int TEST_MAX_INDEX = 1000;
+
+    protected RequestContext requestCtx;
+
+    protected ProfileRequestContext profileRequestCtx;
+
+    private MemoryStorageService storageService;
+
+    private StatusListIndexAllocator allocator;
+
+    private AllocateIndex action;
+
+    private MockHttpServletRequest httpRequest;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+
+        allocator = new StatusListIndexAllocator();
+        allocator.setStorage(storageService);
+        allocator.setMaxIndex(TEST_MAX_INDEX);
+        allocator.initialize();
+
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+
+        httpRequest = new MockHttpServletRequest();
+        httpRequest.setScheme("https");
+        httpRequest.setServerName("issuer.example.org");
+        httpRequest.setServerPort(443);
+
+        action = new AllocateIndex();
+        action.setStatusListAllocator(allocator);
+        action.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+            public HttpServletRequest get() {
+                return httpRequest;
+            }
+        });
+        action.initialize();
+    }
+
+    @AfterMethod
+    protected void tearDown() {
+        if (allocator != null) {
+            allocator.destroy();
+            allocator = null;
+        }
+        if (storageService != null) {
+            storageService.destroy();
+            storageService = null;
+        }
+    }
+
+    @Test
+    public void testInitFailsWithoutAllocator() {
+        AllocateIndex a = new AllocateIndex();
+        try {
+            a.initialize();
+            Assert.fail("Missing allocator should have caused initialization failure");
+        } catch (ComponentInitializationException e) {
+        }
+    }
+
+    @Test
+    public void testInitFailsWithNullAllocator() {
+        AllocateIndex a = new AllocateIndex();
+        try {
+            a.setStatusListAllocator(null);
+            Assert.fail("Null allocator should have caused constraint violation");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testSuccess() {
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+        Assert.assertNotNull(ctx);
+        Assert.assertEquals(ctx.getIndex(), 0);
+        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+    }
+
+    @Test
+    public void testSuccessSequentialAllocations() {
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext firstCtx = profileRequestCtx.getOutboundMessageContext()
+                .getSubcontext(StatusListContext.class);
+        Assert.assertEquals(firstCtx.getIndex(), 0);
+        Assert.assertEquals(firstCtx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext secondCtx = profileRequestCtx.getOutboundMessageContext()
+                .getSubcontext(StatusListContext.class);
+        Assert.assertEquals(secondCtx.getIndex(), 1);
+        Assert.assertEquals(secondCtx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+    }
+
+    @Test
+    public void testRolloverUrlPath() throws IOException {
+        for (int i = 0; i < TEST_MAX_INDEX; i++) {
+            allocator.allocate();
+        }
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+        Assert.assertEquals(ctx.getIndex(), 0);
+        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/1");
+    }
+
+    @Test
+    public void testCustomPathPrefix() throws ComponentInitializationException {
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(allocator);
+        a.setPathPrefix("/custom/status");
+        a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+            public HttpServletRequest get() {
+                return httpRequest;
+            }
+        });
+        a.initialize();
+        ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
+        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/custom/status/0");
+    }
+
+    @Test
+    public void testNonDefaultPortIncludedInUrl() throws ComponentInitializationException {
+        httpRequest.setServerPort(8443);
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org:8443/idp/profile/statusList/0");
+    }
+
+    @Test
+    public void testNoHttpServletRequest() throws ComponentInitializationException {
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(allocator);
+        a.initialize();
+        ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+    }
+
+    @Test
+    public void testNoOutboundMessageContext() {
+        profileRequestCtx.setOutboundMessageContext(null);
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+    }
+
+    @Test
+    public void testAllocatorIoError() throws ComponentInitializationException {
+        StatusListIndexAllocator failing = new StatusListIndexAllocator() {
+            @Override
+            public StatusListIndex allocate() throws IOException {
+                throw new IOException("simulated storage failure");
+            }
+        };
+        failing.setStorage(storageService);
+        failing.setMaxIndex(TEST_MAX_INDEX);
+        failing.initialize();
+
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(failing);
+        a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+            public HttpServletRequest get() {
+                return httpRequest;
+            }
+        });
+        a.initialize();
+
+        ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.IO_ERROR);
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java
new file mode 100644
index 0000000..d54cb2f
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.oauth.statuslist.storage;
+
+import java.io.IOException;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.opensaml.storage.impl.client.ClientStorageService;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link StatusListIndexAllocator}
+ */
+public class StatusListIndexAllocatorTest {
+
+    private MemoryStorageService storageService;
+
+    private StatusListIndexAllocator allocator;
+
+    private static final int TEST_MAX_INDEX = 1000;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+        allocator = new StatusListIndexAllocator();
+        allocator.setStorage(storageService);
+        allocator.setMaxIndex(TEST_MAX_INDEX);
+        allocator.initialize();
+    }
+
+    @AfterMethod
+    protected void tearDown() {
+        allocator.destroy();
+        allocator = null;
+        storageService.destroy();
+        storageService = null;
+    }
+
+    @Test
+    public void testInit() {
+        StatusListIndexAllocator listAllocator = new StatusListIndexAllocator();
+        try {
+            listAllocator.setStorage(null);
+            Assert.fail("Null StorageService should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            listAllocator.setStorage(new ClientStorageService());
+            Assert.fail("ClientStorageService should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            listAllocator.setMaxIndex(999);
+            Assert.fail("maxIndex below 1000 should have caused constraint violation");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testStorageGetter() {
+        Assert.assertEquals(storageService, allocator.getStorage());
+    }
+
+    @Test
+    public void testFirstAllocation() throws IOException {
+        StatusListIndex first = allocator.allocate();
+        Assert.assertEquals(first.index(), 0);
+        Assert.assertEquals(first.uriIndex(), 0);
+    }
+
+    @Test
+    public void testMonotonic() throws IOException {
+        StatusListIndex first = allocator.allocate();
+        StatusListIndex second = allocator.allocate();
+        StatusListIndex third = allocator.allocate();
+        Assert.assertEquals(first.index(), 0);
+        Assert.assertEquals(second.index(), 1);
+        Assert.assertEquals(third.index(), 2);
+        Assert.assertEquals(first.uriIndex(), 0);
+        Assert.assertEquals(second.uriIndex(), 0);
+        Assert.assertEquals(third.uriIndex(), 0);
+    }
+
+    @Test
+    public void testRollover() throws IOException {
+        StatusListIndex last = null;
+        for (int i = 0; i < TEST_MAX_INDEX; i++) {
+            last = allocator.allocate();
+        }
+        Assert.assertEquals(last.index(), TEST_MAX_INDEX - 1);
+        Assert.assertEquals(last.uriIndex(), 0);
+        StatusListIndex rolled = allocator.allocate();
+        Assert.assertEquals(rolled.index(), 0);
+        Assert.assertEquals(rolled.uriIndex(), 1);
+        StatusListIndex afterRoll = allocator.allocate();
+        Assert.assertEquals(afterRoll.index(), 1);
+        Assert.assertEquals(afterRoll.uriIndex(), 1);
+    }
+
+    @Test
+    public void testPersistenceAcrossAllocatorRestart() throws Exception {
+        allocator.allocate();
+        allocator.allocate();
+
+        allocator.destroy();
+        allocator = new StatusListIndexAllocator();
+        allocator.setStorage(storageService);
+        allocator.setMaxIndex(TEST_MAX_INDEX);
+        allocator.initialize();
+
+        StatusListIndex next = allocator.allocate();
+        Assert.assertEquals(next.index(), 2);
+        Assert.assertEquals(next.uriIndex(), 0);
+    }
+
+}

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


More information about the commits mailing list