[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