[java-idp-plugin-vci] branch main updated: Initial full status list implementation and integration
Codeberg
noreply at shibboleth.net
Fri Aug 21 09:48:16 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/df6b58012d0f86f199cec1fdf822bc012b27f390
The following commit(s) were added to refs/heads/main by this push:
new df6b580 Initial full status list implementation and integration
df6b580 is described below
commit df6b58012d0f86f199cec1fdf822bc012b27f390
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Fri Aug 21 12:48:07 2026 +0300
Initial full status list implementation and integration
---
.../oauth/statuslist/MonthlyBucketStrategy.java | 83 +++++++
...{StatusListIndex.java => StatusListBucket.java} | 19 +-
.../plugin/oauth/statuslist/StatusListCodec.java | 159 +++++++++++++
.../oauth/statuslist/StatusListParameters.java | 102 ++++++++
.../statuslist/context/StatusListContext.java | 77 +++---
.../statuslist/context/StatusListTokenContext.java | 79 +++++++
.../messaging/error/StatusListError.java | 47 ++++
.../statuslist/profile/StatusListEventIds.java | 42 ++++
.../openidvci/credential/CredentialDefinition.java | 27 +--
.../credential/CredentialConfigurationTest.java | 1 -
.../credential/CredentialDefinitionTest.java | 3 +-
.../credentials/CredentialDefinition.json | 3 +-
.../logic/CredentialIssuerLookupFunction.java | 156 ++++++++++++
.../DefaultStatusListAssignmentsConfiguration.java | 41 ++++
.../config/DefaultStatusListConfiguration.java | 46 ++++
.../DefaultStatusListRevokeConfiguration.java | 41 ++++
.../impl/StatusListAssignmentsRequestDecoder.java | 58 +++++
.../impl/StatusListRevokeRequestDecoder.java | 59 +++++
.../impl/StatusListAssignmentsRequest.java | 196 ++++++++++++++++
.../impl/StatusListAssignmentsSuccessResponse.java | 83 +++++++
.../messaging/impl/StatusListRevokeRequest.java | 180 ++++++++++++++
.../impl/StatusListRevokeSuccessResponse.java | 44 ++++
.../messaging/impl/StatusListSuccessResponse.java | 94 ++++++++
.../statuslist/profile/impl/AllocateIndex.java | 111 +++++++--
.../oauth/statuslist/profile/impl/AssignIndex.java | 167 +++++++++++++
...tboundStatusListAssignmentsResponseMessage.java | 116 +++++++++
.../FormOutboundStatusListResponseMessage.java | 102 ++++++++
...ormOutboundStatusListRevokeResponseMessage.java | 37 +++
.../profile/impl/FormStatusListToken.java | 214 +++++++++++++++++
.../profile/impl/RevokeStatusListIndex.java | 129 ++++++++++
.../profile/impl/SignStatusListToken.java | 224 ++++++++++++++++++
.../storage/FeistelIndexPermutation.java | 122 ++++++++++
.../statuslist/storage/StatusListAssignment.java | 183 +++++++++++++++
.../storage/StatusListAssignmentStore.java | 261 +++++++++++++++++++++
.../statuslist/storage/StatusListAssignments.java | 104 ++++++++
.../storage/StatusListIndexAllocator.java | 195 ++++++++++-----
.../oauth/statuslist/storage/StatusListStore.java | 242 +++++++++++++++++++
.../messaging/context/CredentialsContext.java | 49 ++++
.../openidvci/profile/impl/AddCredentialShell.java | 89 ++++---
...FormJsonLdSelectiveDisclosureJWTCredential.java | 7 +-
.../plugin/openidvci/profile/impl/ParseProof.java | 2 +
.../profile/impl/ResolveCredentialLifetime.java | 145 ++++++++++++
.../plugin/openidvci/profile/impl/UnwrapGrant.java | 2 +-
.../logic/CredentialCountLookupFunction.java | 58 +++++
.../logic/CredentialDetailsLookupFunction.java | 92 ++++++++
.../logic/CredentialExpirationLookupFunction.java | 50 ++++
.../oauth/status-list/assign/assign-beans.xml | 32 ++-
.../status-list/assignments/assignments-beans.xml | 61 +++++
.../status-list/assignments/assignments-flow.xml | 26 ++
.../flows/oauth/status-list/list/list-beans.xml | 86 +++++++
.../status-list/list/list-flow.xml} | 5 +-
.../oauth/status-list/revoke/revoke-beans.xml | 65 +++++
.../flows/oauth/status-list/revoke/revoke-flow.xml | 26 ++
.../openid/vci/abstract-api/abstract-api-flow.xml | 2 +-
.../flows/openid/vci/abstract/abstract-flow.xml | 3 +-
.../vci/credential-offer/credential-offer-flow.xml | 2 +-
.../openid/vci/credentials/credentials-beans.xml | 15 +-
.../openid/vci/credentials/credentials-flow.xml | 9 +-
.../idp/flows/openid/vci/nonce/nonce-flow.xml | 2 +-
.../idp/flows/openid/vci/token/token-flow.xml | 2 +-
.../idp/service/relying-party/postconfig.xml | 12 +-
.../plugin/openidvci/conf/openid-vci.properties | 5 +-
.../logic/CredentialIssuerLookupFunctionTest.java | 68 ++++++
.../oauth/statuslist/StatusListCodecTest.java | 162 +++++++++++++
.../statuslist/profile/impl/AllocateIndexTest.java | 179 +++++++++-----
.../storage/FeistelIndexPermutationTest.java | 162 +++++++++++++
.../storage/StatusListAssignmentStoreTest.java | 233 ++++++++++++++++++
.../storage/StatusListIndexAllocatorTest.java | 164 +++++++++----
.../statuslist/storage/StatusListStoreTest.java | 199 ++++++++++++++++
.../resources/conf/verifiable-credentials.json | 3 +-
70 files changed, 5554 insertions(+), 310 deletions(-)
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java
new file mode 100644
index 0000000..5e5de32
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java
@@ -0,0 +1,83 @@
+/*
+ * 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;
+
+import java.time.Instant;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Maps expiration of a credential to a {@link StatusListBucket}, one bucket per
+ * calendar month.
+ *
+ * The bucket is UTC month of expiration and its end is the start of the next
+ * month.
+ */
+public class MonthlyBucketStrategy implements Function<Instant, StatusListBucket> {
+
+ /** Bucket identifier format. */
+ @Nonnull
+ private static final DateTimeFormatter ID_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM");
+
+ /**
+ * Resolve bucket of an identifier.
+ *
+ * @param id identifier to resolve
+ * @return the bucket, or null if the identifier is not a bucket
+ */
+ @Nullable
+ public static StatusListBucket parse(@Nullable final String id) {
+ if (id == null) {
+ return null;
+ }
+ try {
+ return of(YearMonth.parse(id, ID_FORMAT));
+ } catch (final DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Build bucket of a month.
+ *
+ * @param month month the bucket serves
+ * @return the bucket
+ */
+ @Nonnull
+ private static StatusListBucket of(@Nonnull final YearMonth month) {
+ return new StatusListBucket(month.format(ID_FORMAT),
+ month.plusMonths(1).atDay(1).atStartOfDay(ZoneOffset.UTC).toInstant());
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public StatusListBucket apply(@Nonnull final Instant expiration) {
+ Constraint.isNotNull(expiration, "Expiration cannot be null");
+
+ return of(YearMonth.from(expiration.atZone(ZoneOffset.UTC)));
+ }
+
+}
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/StatusListBucket.java
similarity index 57%
rename from openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java
rename to openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListBucket.java
index 831855a..8a3d844 100644
--- 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/StatusListBucket.java
@@ -16,18 +16,19 @@
package org.geant.shibboleth.plugin.oauth.statuslist;
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+
/**
- * Immutable value identifying a single slot in a Status List, as in
+ * Immutable identity of one 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.
+ * A bucket holds credentials that all expire before the end of the bucket.
*
- * @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)
+ * @param id identifier, used as storage key
+ * @param end upper bound for expiration of any credential in this bucket
*/
-public record StatusListIndex(long index, long uriIndex) { }
+public record StatusListBucket(@Nonnull String id, @Nonnull Instant end) {
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java
new file mode 100644
index 0000000..2ca2bc9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java
@@ -0,0 +1,159 @@
+/*
+ * 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;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.zip.Deflater;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.jose.util.Base64URL;
+
+/**
+ * Encoding of a status list as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ *
+ * A whole list is compressed, this is the 'lst' member of a token. A piece of a
+ * list is stored uncompressed.
+ */
+public final class StatusListCodec {
+
+ /** Constructor. */
+ private StatusListCodec() {
+ }
+
+ /**
+ * Get status list with every slot valid.
+ *
+ * @return uncompressed status list
+ */
+ @Nonnull
+ public static byte[] empty() {
+ return new byte[StatusListParameters.BYTE_LENGTH];
+ }
+
+ /**
+ * Encode status list.
+ *
+ * @param list uncompressed status list
+ * @return encoded status list
+ */
+ @Nonnull
+ public static String encode(@Nonnull final byte[] list) {
+ final Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION);
+ try {
+ deflater.setInput(list);
+ deflater.finish();
+
+ final ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+ final byte[] chunk = new byte[8192];
+ while (!deflater.finished()) {
+ compressed.write(chunk, 0, deflater.deflate(chunk));
+ }
+
+ return Base64URL.encode(compressed.toByteArray()).toString();
+ } finally {
+ deflater.end();
+ }
+ }
+
+ /**
+ * Get a piece of a status list with every slot valid.
+ *
+ * @return uncompressed piece of a status list
+ */
+ @Nonnull
+ public static byte[] emptyChunk() {
+ return new byte[StatusListParameters.CHUNK_BYTE_LENGTH];
+ }
+
+ /**
+ * Encode a piece of a status list for storage. Not compressed.
+ *
+ * @param chunk uncompressed piece of a status list
+ * @return encoded piece
+ */
+ @Nonnull
+ public static String encodeChunk(@Nonnull final byte[] chunk) {
+ return Base64URL.encode(chunk).toString();
+ }
+
+ /**
+ * Decode a piece of a status list.
+ *
+ * @param encoded encoded piece
+ * @return uncompressed piece of a status list
+ * @throws IOException if the value is not a piece of the expected length
+ */
+ @Nonnull
+ public static byte[] decodeChunk(@Nonnull final String encoded) throws IOException {
+ final byte[] chunk = new Base64URL(encoded).decode();
+ if (chunk.length != StatusListParameters.CHUNK_BYTE_LENGTH) {
+ throw new IOException("Status list piece of " + chunk.length + " bytes, expected "
+ + StatusListParameters.CHUNK_BYTE_LENGTH);
+ }
+ return chunk;
+ }
+
+ /**
+ * Set status of one slot.
+ *
+ * @param list uncompressed status list, or a piece of one
+ * @param index slot to set
+ * @param valid whether the slot is to read as valid
+ */
+ public static void setStatus(@Nonnull final byte[] list, final long index, final boolean valid) {
+ checkIndex(list, index);
+
+ final int position = (int) (index / 8);
+ final int bit = 1 << (int) (index % 8);
+
+ if (valid) {
+ list[position] &= (byte) ~bit;
+ } else {
+ list[position] |= (byte) bit;
+ }
+ }
+
+ /**
+ * Get whether slot reads as valid.
+ *
+ * @param list uncompressed status list, or a piece of one
+ * @param index slot to read
+ * @return whether the slot reads as valid
+ */
+ public static boolean isValid(@Nonnull final byte[] list, final long index) {
+ checkIndex(list, index);
+
+ return (list[(int) (index / 8)] & (1 << (int) (index % 8))) == 0;
+ }
+
+ /**
+ * Check slot is within the array addressed.
+ *
+ * @param list status list, or a piece of one
+ * @param index slot to check
+ */
+ private static void checkIndex(@Nonnull final byte[] list, final long index) {
+ if (index < 0 || index >= (long) list.length * 8) {
+ throw new IndexOutOfBoundsException("Slot " + index + " outside " + list.length * 8 + " slots");
+ }
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java
new file mode 100644
index 0000000..505d8f2
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java
@@ -0,0 +1,102 @@
+/*
+ * 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;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Fixed dimensions of a Status List as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ *
+ * Allocation and publishing use the same values. Changing any of these breaks
+ * credentials already issued.
+ */
+public final class StatusListParameters {
+
+ /** Width in bits of the slot space of a single list. */
+ public static final int INDEX_BITS = 20;
+
+ /** Number of slots in single list. */
+ public static final long CAPACITY = 1L << INDEX_BITS;
+
+ /** Bits describing status of one slot. */
+ public static final int STATUS_BITS = 1;
+
+ /** Length in bytes of the uncompressed status list. */
+ public static final int BYTE_LENGTH = (int) (CAPACITY * STATUS_BITS / 8);
+
+ /**
+ * Number of pieces a list is stored in.
+ *
+ * Revoking a slot rewrites one piece only and no stored value grows to the size
+ * of a fully revoked list. We need chunks as there is maximun field size 'text'
+ * type of sql fieds and we try not to cross it.
+ *
+ * Changing the parameter makes old storage records incompatible.
+ */
+ public static final int CHUNKS = 8;
+
+ /** Length in bytes of one piece of list. */
+ public static final int CHUNK_BYTE_LENGTH = BYTE_LENGTH / CHUNKS;
+
+ /** Number of slots described by one piece of list. */
+ public static final long CHUNK_CAPACITY = CAPACITY / CHUNKS;
+
+ /**
+ * Get piece of a list holding a slot.
+ *
+ * @param index slot to locate
+ * @return piece holding the slot
+ */
+ public static int chunkOf(final long index) {
+ return (int) (index / CHUNK_CAPACITY);
+ }
+
+ /**
+ * Get the position of slot within the piece holding it.
+ *
+ * @param index slot to locate
+ * @return position within the piece
+ */
+ public static long offsetInChunk(final long index) {
+ return index % CHUNK_CAPACITY;
+ }
+
+ /**
+ * Time a list is kept in storage after the bucket has ended. Only for clock
+ * skew at verifiers.
+ */
+ @Nonnull
+ public static final Duration RETENTION = Duration.ofDays(7);
+
+ /**
+ * Query parameter for the list a request is for. Appears in the url of a
+ * credential.
+ */
+ public static final String BUCKET_PARAMETER = "id";
+
+ /** Member for the slot of a list, named as in a credential status reference. */
+ public static final String INDEX_PARAMETER = "idx";
+
+ /** Constructor. */
+ private StatusListParameters() {
+ }
+
+}
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
index c97f1a9..0030101 100644
--- 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
@@ -16,57 +16,78 @@
package org.geant.shibboleth.plugin.oauth.statuslist.context;
+import java.util.List;
+
+import javax.annotation.Nullable;
+
import org.opensaml.messaging.context.BaseContext;
/**
- * Subcontext for carrying allocated index and path component it's status list
- * url as in
+ * Subcontext for status list slots allocated for the issuance, 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;
+ /** Status list url allocated indices belong to. */
+ @Nullable
+ private String statusListUri;
- /** Path component of allocated status list url for allocated index. */
- private String statusListUrlPath;
+ /** Allocated status list indices, one per credential. */
+ @Nullable
+ private List<Long> indices;
/**
- * Get allocated status list index.
- *
- * @return Allocated status list index
+ * Get status list url the allocated indices belong to.
+ *
+ * @return Status list url the allocated indices belong to
*/
- public long getIndex() {
- return index;
+ @Nullable
+ public String getStatusListUri() {
+ return statusListUri;
}
/**
- * Set allocated status list index.
- *
- * @param index Allocated status list index
+ * Set status list url allocated indices belong to.
+ *
+ * @param uri Status list url allocated indices belong to
*/
- public void setIndex(long index) {
- this.index = index;
+ public void setStatusListUri(@Nullable final String uri) {
+ statusListUri = uri;
}
/**
- * Get Path component of allocated status list url for allocated index.
- *
- * @return path component of allocated status list url for allocated index
+ * Get allocated status list indices, one per credential.
+ *
+ * @return Allocated status list indices, one per credential
*/
- public String getStatusListUrlPath() {
- return statusListUrlPath;
+ @Nullable
+ public List<Long> getIndices() {
+ return indices;
}
/**
- * Set path component of allocated status list url for allocated index.
- *
- * @param statusListUrlPath Path component of allocated status list url for
- * allocated index
+ * Set allocated status list indices, one per credential.
+ *
+ * @param allocated Allocated status list indices, one per credential
*/
- public void setStatusListUrlPath(String statusListUrlPath) {
- this.statusListUrlPath = statusListUrlPath;
+ public void setIndices(@Nullable final List<Long> allocated) {
+ indices = allocated == null ? null : List.copyOf(allocated);
}
-}
\ No newline at end of file
+
+ /**
+ * Get allocated index of a credential.
+ *
+ * @param position of the credential in this issuance
+ * @return allocated index, or null if there is no index for the position
+ */
+ @Nullable
+ public Long getIndex(final int ordinal) {
+ if (indices == null || ordinal < 0 || ordinal >= indices.size()) {
+ return null;
+ }
+ return indices.get(ordinal);
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java
new file mode 100644
index 0000000..1d0954d
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java
@@ -0,0 +1,79 @@
+/*
+ * 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 javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+/**
+ * Subcontext for the Status List Token to be published, 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 StatusListTokenContext extends BaseContext {
+
+ /** Claims of status list token, before signing. */
+ @Nullable
+ private JWTClaimsSet claims;
+
+ /** Signed status list token. */
+ @Nullable
+ private String signedToken;
+
+ /**
+ * Get claims of status list token, before signing.
+ *
+ * @return Claims of status list token, before signing
+ */
+ @Nullable
+ public JWTClaimsSet getClaims() {
+ return claims;
+ }
+
+ /**
+ * Set claims of status list token, before signing.
+ *
+ * @param tokenClaims Claims of the status list token, before signing
+ */
+ public void setClaims(@Nullable final JWTClaimsSet tokenClaims) {
+ claims = tokenClaims;
+ }
+
+ /**
+ * Get signed status list token.
+ *
+ * @return Signed status list token
+ */
+ @Nullable
+ public String getSignedToken() {
+ return signedToken;
+ }
+
+ /**
+ * Set signed status list token.
+ *
+ * @param token Signed status list token
+ */
+ public void setSignedToken(@Nullable final String token) {
+ signedToken = token;
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java
new file mode 100644
index 0000000..61160e9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java
@@ -0,0 +1,47 @@
+/*
+ * 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.messaging.error;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.oauth2.sdk.ErrorObject;
+
+/**
+ * Status list errors.
+ */
+public final class StatusListError {
+
+ /** No list of that name is published. */
+ @Nonnull
+ public static final ErrorObject UNKNOWN_STATUS_LIST = new ErrorObject("unknown_status_list",
+ "No status list of that name.", 404);
+
+ /**
+ * The list is not published anymore.
+ *
+ * Separate from an unknown list, so that a verifier can tell a retired list
+ * from one that never existed.
+ */
+ @Nonnull
+ public static final ErrorObject RETIRED_STATUS_LIST = new ErrorObject("retired_status_list",
+ "Status list is no longer published, every credential in it having expired.", 410);
+
+ /** Constructor. */
+ private StatusListError() {
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java
new file mode 100644
index 0000000..e06e57d
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Status list event constants.
+ */
+public final class StatusListEventIds {
+
+ /** The requested list is not a list of this issuer. */
+ @Nonnull
+ @NotEmpty
+ public static final String NO_STATUS_LIST = "NoStatusList";
+
+ /** The requested list has ended and no credential of it can be valid. */
+ @Nonnull
+ @NotEmpty
+ public static final String STATUS_LIST_RETIRED = "StatusListRetired";
+
+ /** Constructor. */
+ private StatusListEventIds() {
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
index 98b3cef..f867050 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
@@ -23,9 +23,7 @@ import javax.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -51,34 +49,18 @@ public class CredentialDefinition {
@JsonProperty("type")
private final List<String> type;
- /** Issuer in credential definition. */
- @Nullable
- @JsonProperty("issuer")
- @JsonInclude(Include.NON_NULL)
- private final String issuer;
-
- /**
- *
- * @return
- */
- public String getIssuer() {
- return issuer;
- }
-
/**
* Constructor.
- *
+ *
* @param context Context of credential definition
- * @param id Id of credential definition
* @param type Type of credential definition
*/
private CredentialDefinition(@JsonProperty("@context") @Nonnull List<String> context,
- @JsonProperty("type") @Nonnull List<String> type, @JsonProperty("issuer") @Nullable String issuer) {
+ @JsonProperty("type") @Nonnull List<String> type) {
assert context != null;
assert type != null;
this.context = context;
this.type = type;
- this.issuer = issuer;
}
/**
@@ -125,11 +107,10 @@ public class CredentialDefinition {
@JsonCreator
public static CredentialDefinition of(@JsonProperty("@context") @Nullable List<String> context,
- @JsonProperty("id") @Nullable String id, @JsonProperty("type") @Nullable List<String> type,
- @JsonProperty("issuer") @Nullable String issuer) {
+ @JsonProperty("id") @Nullable String id, @JsonProperty("type") @Nullable List<String> type) {
if (context == null || type == null) {
return null;
}
- return new CredentialDefinition(context, type, issuer);
+ return new CredentialDefinition(context, type);
}
}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
index 99b33bf..bfaa18c 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
@@ -164,7 +164,6 @@ public class CredentialConfigurationTest {
Assert.assertEquals(configuration.getFormat(), "vc+sd-jwt");
Assert.assertNotNull(configuration.getCredentialDefinition());
Assert.assertEquals(configuration.getCredentialDefinition().getType().get(1), "IdentityCredential");
- Assert.assertEquals(configuration.getCredentialDefinition().getIssuer(), "https://issuer.example.org");
Assert.assertNull(configuration.getVct());
}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
index 2eaedbd..e55aecd 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
@@ -48,9 +48,8 @@ public class CredentialDefinitionTest {
"https://www.w3.org/2018/credentials/examples/v1");
Assert.assertEquals(credentialDefinition.getType().get(0), "VerifiableCredential");
Assert.assertEquals(credentialDefinition.getType().get(1), "UniversityDegreeCredential");
- Assert.assertEquals(credentialDefinition.getIssuer(), "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0");
Assert.assertEquals(credentialDefinition.serialize(),
- "{\"@context\":[\"https://www.w3.org/2018/credentials/v1\",\"https://www.w3.org/2018/credentials/examples/v1\"],\"type\":[\"VerifiableCredential\",\"UniversityDegreeCredential\"],\"issuer\":\"did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0\"}");
+ "{\"@context\":[\"https://www.w3.org/2018/credentials/v1\",\"https://www.w3.org/2018/credentials/examples/v1\"],\"type\":[\"VerifiableCredential\",\"UniversityDegreeCredential\"]}");
}
diff --git a/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json b/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
index fb21edf..5a74cd9 100644
--- a/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
+++ b/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
@@ -6,6 +6,5 @@
"type": [
"VerifiableCredential",
"UniversityDegreeCredential"
- ],
- "issuer": "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0"
+ ]
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java
new file mode 100644
index 0000000..3481ce4
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java
@@ -0,0 +1,156 @@
+/*
+ * 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.profile.logic;
+
+import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.util.Base64URL;
+
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Issuer identifier for everything this plugin signs. By default the
+ * <a href="https://github.com/quartzjer/did-jwk/blob/main/spec.md">did:jwk</a>
+ * of the resolved signing key. Setting an identifier overrides this, that is a
+ * placeholder for future types and this all propably needs additional
+ * modifications at that point.
+ *
+ * TODO: Whole did:jwk and key rotation needs still thinking over. Current
+ * implementation is pretty much stuck with one key.
+ */
+public class CredentialIssuerLookupFunction implements Function<ProfileRequestContext, String> {
+
+ /** Prefix of a did:jwk identifier. */
+ @Nonnull
+ private static final String PREFIX = "did:jwk:";
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(CredentialIssuerLookupFunction.class);
+
+ /** Strategy used to locate {@link SecurityParametersContext} to read. */
+ @Nonnull
+ private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+ /** Issuer identifier to use in place of one derived from the signing key. */
+ @Nullable
+ private String configuredIssuer;
+
+ /** Constructor. */
+ public CredentialIssuerLookupFunction() {
+ final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+ SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+ assert splcs != null;
+ securityParametersLookupStrategy = splcs;
+ }
+
+ /**
+ * Set strategy used to locate the {@link SecurityParametersContext} to read.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setSecurityParametersLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+ securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+ "SecurityParametersContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Set an issuer identifier to use in place of one derived from the signing key.
+ *
+ * Placeholder for future types and this all propably needs additional
+ * modifications at that point.
+ *
+ * @param issuer issuer identifier, or null to derive one
+ */
+ public void setIssuer(@Nullable final String issuer) {
+ if (issuer != null && !issuer.isEmpty()) {
+ configuredIssuer = issuer;
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
+ if (configuredIssuer != null) {
+ return configuredIssuer;
+ }
+ if (profileRequestContext == null) {
+ return null;
+ }
+
+ final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+ if (secParamCtx == null) {
+ log.debug("No security parameters context, unable to derive issuer");
+ return null;
+ }
+
+ final SignatureSigningParameters params = secParamCtx.getSignatureSigningParameters();
+ if (params == null) {
+ log.debug("No signature signing parameters, unable to derive issuer");
+ return null;
+ }
+
+ final Credential credential = params.getSigningCredential();
+ if (credential == null) {
+ log.debug("No signing credential, unable to derive issuer");
+ return null;
+ }
+
+ return toDidJwk(credential.getPublicKey());
+ }
+
+ /**
+ * Encode a public key as a did:jwk identifier.
+ *
+ * @param publicKey key to encode
+ * @return did:jwk identifier, or null if the key is of an unsupported type
+ */
+ @Nullable
+ public String toDidJwk(@Nullable final PublicKey publicKey) {
+ if (!(publicKey instanceof final ECPublicKey ecKey)) {
+ log.error("Signing key is not an EC key, unable to derive a did:jwk issuer");
+ return null;
+ }
+
+ final Curve curve = Curve.forECParameterSpec(ecKey.getParams());
+ if (curve == null) {
+ log.error("Unrecognised EC curve, unable to derive a did:jwk issuer");
+ return null;
+ }
+
+ return PREFIX + Base64URL.encode(new ECKey.Builder(curve, ecKey).build().toJSONString()).toString();
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java
new file mode 100644
index 0000000..f9643b5
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java
@@ -0,0 +1,41 @@
+/*
+ * 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.config;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.config.impl.AbstractOpenIDVCIConfiguration;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint reading out records of status list
+ * assignments.
+ */
+public class DefaultStatusListAssignmentsConfiguration extends AbstractOpenIDVCIConfiguration {
+
+ /** ID for status list assignments profile configuration. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list/assignments";
+
+ /** Constructor. */
+ public DefaultStatusListAssignmentsConfiguration() {
+ super(PROFILE_ID);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java
new file mode 100644
index 0000000..589edcb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java
@@ -0,0 +1,46 @@
+/*
+ * 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.config;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint publishing Status List Tokens, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class DefaultStatusListConfiguration extends AbstractInterceptorAwareProfileConfiguration {
+
+ /** Status list specification URI. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROTOCOL_URI = "https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/";
+
+ /** ID for status list profile configuration. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list";
+
+ /** Constructor. */
+ public DefaultStatusListConfiguration() {
+ super(PROFILE_ID);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java
new file mode 100644
index 0000000..d7e1220
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java
@@ -0,0 +1,41 @@
+/*
+ * 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.config;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.config.impl.AbstractOpenIDVCIConfiguration;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint revoking a slot of a status list.
+ *
+ */
+public class DefaultStatusListRevokeConfiguration extends AbstractOpenIDVCIConfiguration {
+
+ /** ID for status list revocation profile configuration. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list/revoke";
+
+ /** Constructor. */
+ public DefaultStatusListRevokeConfiguration() {
+ super(PROFILE_ID);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java
new file mode 100644
index 0000000..17156ef
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java
@@ -0,0 +1,58 @@
+/*
+ * 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.decoding.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsRequest;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.BaseOpenIDVCIRequestDecoder;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.RequestUtil;
+import org.opensaml.messaging.decoder.MessageDecodingException;
+
+import com.google.common.base.MoreObjects;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.JakartaServletUtils;
+
+/**
+ * Decoder for {@link StatusListAssignmentsRequest}.
+ */
+public class StatusListAssignmentsRequestDecoder
+ extends BaseOpenIDVCIRequestDecoder<StatusListAssignmentsRequest> {
+
+ /** {@inheritDoc} */
+ @Override
+ protected StatusListAssignmentsRequest parseMessage() throws MessageDecodingException {
+ try {
+ final HTTPRequest httpRequest = JakartaServletUtils.createHTTPRequest(getHttpServletRequest());
+ getProtocolMessageLogger().trace("Inbound request {}", RequestUtil.toString(httpRequest));
+ return StatusListAssignmentsRequest.parse(httpRequest);
+ } catch (final ParseException | IOException e) {
+ throw new MessageDecodingException(e);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected String getMessageToLog(@Nonnull final StatusListAssignmentsRequest message) {
+ return MoreObjects.toStringHelper(this).omitNullValues().add("from", message.getFrom())
+ .add("limit", message.getLimit()).toString();
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java
new file mode 100644
index 0000000..5bacc74
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java
@@ -0,0 +1,59 @@
+/*
+ * 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.decoding.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeRequest;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.BaseOpenIDVCIRequestDecoder;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.RequestUtil;
+import org.opensaml.messaging.decoder.MessageDecodingException;
+
+import com.google.common.base.MoreObjects;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.JakartaServletUtils;
+
+/**
+ * Decoder for {@link StatusListRevokeRequest}.
+ */
+public class StatusListRevokeRequestDecoder extends BaseOpenIDVCIRequestDecoder<StatusListRevokeRequest> {
+
+ /** {@inheritDoc} */
+ @Override
+ protected StatusListRevokeRequest parseMessage() throws MessageDecodingException {
+ try {
+ final HTTPRequest httpRequest = JakartaServletUtils.createHTTPRequest(getHttpServletRequest());
+ getProtocolMessageLogger().trace("Inbound request {}", RequestUtil.toString(httpRequest));
+ return StatusListRevokeRequest.parse(httpRequest);
+ } catch (final ParseException | IOException e) {
+ throw new MessageDecodingException(e);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected String getMessageToLog(@Nonnull final StatusListRevokeRequest message) {
+ return MoreObjects.toStringHelper(this).omitNullValues()
+ .add(StatusListParameters.BUCKET_PARAMETER, message.getId())
+ .add(StatusListParameters.INDEX_PARAMETER, message.getIndex()).toString();
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java
new file mode 100644
index 0000000..48e8397
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.messaging.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.AbstractOptionallyIdentifiedRequest;
+import com.nimbusds.oauth2.sdk.OAuth2Error;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+/**
+ * Request for a page of records of status list assignments.
+ */
+public class StatusListAssignmentsRequest extends AbstractOptionallyIdentifiedRequest {
+
+ /** Records returned when a request asks for no particular number. */
+ public static final int DEFAULT_LIMIT = 100;
+
+ /** Most records one request may ask for. */
+ public static final int MAX_LIMIT = 1000;
+
+ /** Member naming the position to read from. */
+ @Nonnull
+ private static final String FROM_KEY = "from";
+
+ /** Member naming how many records to return. */
+ @Nonnull
+ private static final String LIMIT_KEY = "limit";
+
+ /** Position to read from. */
+ private long from;
+
+ /** Records to return. */
+ private int limit = DEFAULT_LIMIT;
+
+ /**
+ * Constructor for a client authenticated request.
+ *
+ * @param uri endpoint the request was made to
+ * @param clientAuthentication client authentication
+ */
+ protected StatusListAssignmentsRequest(@Nullable final URI uri,
+ @Nonnull final ClientAuthentication clientAuthentication) {
+ super(uri, clientAuthentication);
+ }
+
+ /**
+ * Constructor for a request identifying its client without authenticating.
+ *
+ * @param uri endpoint the request was made to
+ * @param clientId client identifier
+ */
+ protected StatusListAssignmentsRequest(@Nullable final URI uri, @Nonnull final ClientID clientId) {
+ super(uri, clientId);
+ }
+
+ /**
+ * Get position to read from.
+ *
+ * @return Position to read from
+ */
+ public long getFrom() {
+ return from;
+ }
+
+ /**
+ * Set position to read from.
+ *
+ * @param position Position to read from
+ */
+ private void setFrom(final long position) {
+ from = position;
+ }
+
+ /**
+ * Get records to return.
+ *
+ * @return Records to return
+ */
+ public int getLimit() {
+ return limit;
+ }
+
+ /**
+ * Set records to return.
+ *
+ * @param records Records to return
+ */
+ private void setLimit(final int records) {
+ limit = records;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPRequest toHTTPRequest() {
+ return null;
+ }
+
+ /**
+ * Parse a request for records of status list assignments.
+ *
+ * @param httpRequest request to parse
+ * @return parsed request
+ * @throws ParseException if the request is not one for records
+ */
+ public static StatusListAssignmentsRequest parse(@Nonnull final HTTPRequest httpRequest) throws ParseException {
+ httpRequest.ensureMethod(HTTPRequest.Method.POST);
+ httpRequest.ensureEntityContentType(ContentType.APPLICATION_JSON);
+
+ final ClientAuthentication clientAuthentication;
+ try {
+ clientAuthentication = ClientAuthentication.parse(httpRequest);
+ } catch (final ParseException e) {
+ throw new ParseException(e.getMessage(),
+ OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+ }
+
+ final Map<String, Object> payload;
+ try {
+ payload = new ObjectMapper().readValue(httpRequest.getQuery(),
+ new TypeReference<Map<String, Object>>() {
+ });
+ } catch (final JsonProcessingException e) {
+ throw new ParseException(e.getMessage(),
+ OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+ }
+
+ final URI uri;
+ try {
+ uri = httpRequest.getURL().toURI();
+ } catch (final URISyntaxException e) {
+ throw new ParseException(e.getMessage(), e);
+ }
+
+ final StatusListAssignmentsRequest request;
+ if (clientAuthentication != null) {
+ request = new StatusListAssignmentsRequest(uri, clientAuthentication);
+ } else {
+ final Object clientId = payload.get("client_id");
+ if (!(clientId instanceof String value) || value.isEmpty()) {
+ throw new ParseException("Missing client authentication",
+ OAuth2Error.INVALID_CLIENT.appendDescription(": no client authentication or client_id"));
+ }
+ request = new StatusListAssignmentsRequest(uri, new ClientID(value));
+ }
+
+ final Object from = payload.get(FROM_KEY);
+ if (from != null) {
+ if (!(from instanceof Number position) || position.longValue() != position.doubleValue()
+ || position.longValue() < 0) {
+ throw new ParseException("Invalid " + FROM_KEY, OAuth2Error.INVALID_REQUEST
+ .appendDescription(": '" + FROM_KEY + "' must be a non-negative integer"));
+ }
+ request.setFrom(position.longValue());
+ }
+
+ final Object limit = payload.get(LIMIT_KEY);
+ if (limit != null) {
+ if (!(limit instanceof Number records) || records.longValue() != records.doubleValue()
+ || records.longValue() < 1 || records.longValue() > MAX_LIMIT) {
+ throw new ParseException("Invalid " + LIMIT_KEY, OAuth2Error.INVALID_REQUEST.appendDescription(
+ ": '" + LIMIT_KEY + "' must be an integer between 1 and " + MAX_LIMIT));
+ }
+ request.setLimit((int) records.longValue());
+ }
+
+ return request;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
new file mode 100644
index 0000000..bb6e6cc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
@@ -0,0 +1,83 @@
+/*
+ * 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.messaging.impl;
+
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignments;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying a page of records of status list assignments.
+ *
+ * The records describe what was issued, so the response is not to be cached.
+ */
+public class StatusListAssignmentsSuccessResponse implements SuccessResponse {
+
+ /** The page of records. */
+ @Nonnull
+ private final StatusListAssignments records;
+
+ /**
+ * Constructor.
+ *
+ * @param page the page of records
+ */
+ public StatusListAssignmentsSuccessResponse(@Nonnull final StatusListAssignments page) {
+ records = page;
+ }
+
+ /**
+ * Get the page of records.
+ *
+ * @return The page of records
+ */
+ @Nonnull
+ public StatusListAssignments getRecords() {
+ return records;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean indicatesSuccess() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPResponse toHTTPResponse() {
+ final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+ httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+ httpResponse.setCacheControl("no-store");
+ httpResponse.setPragma("no-cache");
+ httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
+ try {
+ httpResponse.setContent(records.serialize());
+ } catch (final JsonProcessingException e) {
+ throw new IllegalStateException("Unable to serialize records of status list assignments", e);
+ }
+ return httpResponse;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java
new file mode 100644
index 0000000..88f7ee8
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.messaging.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.AbstractOptionallyIdentifiedRequest;
+import com.nimbusds.oauth2.sdk.OAuth2Error;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+/**
+ * Request to revoke one slot of a status list, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class StatusListRevokeRequest extends AbstractOptionallyIdentifiedRequest {
+
+ /** Identifier of the list holding the slot. */
+ @Nullable
+ private String id;
+
+ /** Slot within the list. */
+ private long index;
+
+ /**
+ * Constructor for a client authenticated request.
+ *
+ * @param uri endpoint the request was made to
+ * @param clientAuthentication client authentication
+ */
+ protected StatusListRevokeRequest(@Nullable final URI uri,
+ @Nonnull final ClientAuthentication clientAuthentication) {
+ super(uri, clientAuthentication);
+ }
+
+ /**
+ * Constructor for a request identifying its client without authenticating.
+ *
+ * @param uri endpoint the request was made to
+ * @param clientId client identifier
+ */
+ protected StatusListRevokeRequest(@Nullable final URI uri, @Nonnull final ClientID clientId) {
+ super(uri, clientId);
+ }
+
+ /**
+ * Get identifier of the list holding the slot.
+ *
+ * @return Identifier of the list holding the slot
+ */
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Set identifier of the list holding the slot.
+ *
+ * @param listId Identifier of the list holding the slot
+ */
+ private void setId(@Nullable final String listId) {
+ id = listId;
+ }
+
+ /**
+ * Get slot within the list.
+ *
+ * @return Slot within the list
+ */
+ public long getIndex() {
+ return index;
+ }
+
+ /**
+ * Set slot within the list.
+ *
+ * @param slot Slot within the list
+ */
+ private void setIndex(final long slot) {
+ index = slot;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPRequest toHTTPRequest() {
+ return null;
+ }
+
+ /**
+ * Parse a revocation request.
+ *
+ * @param httpRequest request to parse
+ * @return parsed request
+ * @throws ParseException if the request is not a revocation request
+ */
+ public static StatusListRevokeRequest parse(@Nonnull final HTTPRequest httpRequest) throws ParseException {
+ httpRequest.ensureMethod(HTTPRequest.Method.POST);
+ httpRequest.ensureEntityContentType(ContentType.APPLICATION_JSON);
+
+ final ClientAuthentication clientAuthentication;
+ try {
+ clientAuthentication = ClientAuthentication.parse(httpRequest);
+ } catch (final ParseException e) {
+ throw new ParseException(e.getMessage(),
+ OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+ }
+
+ final Map<String, Object> payload;
+ try {
+ payload = new ObjectMapper().readValue(httpRequest.getQuery(), new TypeReference<Map<String, Object>>() {
+ });
+ } catch (final JsonProcessingException e) {
+ throw new ParseException(e.getMessage(),
+ OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+ }
+
+ final URI uri;
+ try {
+ uri = httpRequest.getURL().toURI();
+ } catch (final URISyntaxException e) {
+ throw new ParseException(e.getMessage(), e);
+ }
+
+ final StatusListRevokeRequest request;
+ if (clientAuthentication != null) {
+ request = new StatusListRevokeRequest(uri, clientAuthentication);
+ } else {
+ final Object clientId = payload.get("client_id");
+ if (!(clientId instanceof String value) || value.isEmpty()) {
+ throw new ParseException("Missing client authentication",
+ OAuth2Error.INVALID_CLIENT.appendDescription(": no client authentication or client_id"));
+ }
+ request = new StatusListRevokeRequest(uri, new ClientID(value));
+ }
+
+ if (!(payload.get(StatusListParameters.BUCKET_PARAMETER) instanceof String listId) || listId.isEmpty()) {
+ throw new ParseException("Missing " + StatusListParameters.BUCKET_PARAMETER, OAuth2Error.INVALID_REQUEST
+ .appendDescription(": '" + StatusListParameters.BUCKET_PARAMETER + "' must be a string"));
+ }
+ request.setId(listId);
+
+ if (!(payload.get(StatusListParameters.INDEX_PARAMETER) instanceof Number slot)
+ || slot.longValue() != slot.doubleValue()) {
+ throw new ParseException("Missing " + StatusListParameters.INDEX_PARAMETER, OAuth2Error.INVALID_REQUEST
+ .appendDescription(": '" + StatusListParameters.INDEX_PARAMETER + "' must be an integer"));
+ }
+ request.setIndex(slot.longValue());
+
+ return request;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java
new file mode 100644
index 0000000..0321466
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java
@@ -0,0 +1,44 @@
+/*
+ * 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.messaging.impl;
+
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response to a successful revocation.
+ */
+public class StatusListRevokeSuccessResponse implements SuccessResponse {
+
+ /** Status of a request that succeeded with nothing to return. */
+ private static final int SC_NO_CONTENT = 204;
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean indicatesSuccess() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPResponse toHTTPResponse() {
+ final HTTPResponse httpResponse = new HTTPResponse(SC_NO_CONTENT);
+ httpResponse.setCacheControl("no-store");
+ return httpResponse;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java
new file mode 100644
index 0000000..5192dd8
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java
@@ -0,0 +1,94 @@
+/*
+ * 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.messaging.impl;
+
+import java.text.ParseException;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying a signed Status List Token, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class StatusListSuccessResponse implements SuccessResponse {
+
+ /** Media type of a status list token. */
+ @Nonnull
+ private static final ContentType CONTENT_TYPE = contentType();
+
+ /** Signed status list token. */
+ @Nonnull
+ private final String token;
+
+ /**
+ * Constructor.
+ *
+ * @param statusListToken signed status list token
+ */
+ public StatusListSuccessResponse(@Nonnull final String statusListToken) {
+ token = statusListToken;
+ }
+
+ /**
+ * Get the signed status list token.
+ *
+ * @return signed status list token in compact serialization
+ */
+ @Nonnull
+ public String getToken() {
+ return token;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean indicatesSuccess() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public HTTPResponse toHTTPResponse() {
+ final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+ httpResponse.setEntityContentType(CONTENT_TYPE);
+ httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
+ // TODO: Cache-Control belongs here
+ httpResponse.setContent(token);
+ return httpResponse;
+ }
+
+ /**
+ * Parse the media type of a status list token.
+ *
+ * @return media type
+ */
+ @Nonnull
+ private static ContentType contentType() {
+ try {
+ return ContentType.parse("application/statuslist+jwt");
+ } catch (final ParseException e) {
+ throw new IllegalStateException("Unable to parse status list media type", e);
+ }
+ }
+
+}
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
index 94bc317..ab9c93b 100644
--- 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
@@ -17,10 +17,16 @@ package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.List;
+import java.util.function.Function;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
import org.opensaml.messaging.context.MessageContext;
@@ -37,9 +43,12 @@ import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
/**
- * Action that allocates a status list slot via {@link StatusListIndexAllocator}
+ * Action that allocates status list slots via {@link StatusListIndexAllocator}
* and populates a {@link StatusListContext} on the outbound
* {@link MessageContext}.
+ *
+ * Bucket is chosen from the expiration of the credentials, list always outlives
+ * the credentials in it.
*/
public class AllocateIndex extends AbstractProfileAction {
@@ -51,15 +60,32 @@ public class AllocateIndex extends AbstractProfileAction {
@NonnullAfterInit
private StatusListIndexAllocator statusListAllocator;
+ /** Strategy used to obtain the expiration shared by the credentials issued. */
+ @NonnullAfterInit
+ private Function<ProfileRequestContext, Instant> expirationLookupStrategy;
+
+ /** Strategy used to obtain the number of credentials being issued. */
+ @NonnullAfterInit
+ private Function<ProfileRequestContext, Integer> indexCountLookupStrategy;
+
/**
- * Path prefixing etc is still a WIP. Minimal implementention to move forward.
- **/
+ * Strategy used to choose bucket an expiration belongs to.
+ */
+ @Nonnull
+ private final Function<Instant, StatusListBucket> bucketStrategy = new MonthlyBucketStrategy();
+
/**
- * Path prefix prepended to the {@code uriIndex} to form the status list URL
- * path.
+ * Path of endpoint publishing status lists. TODO: Maybe not hadcoded here.
*/
@Nonnull
- private String pathPrefix = "/idp/profile/statusList";
+ private String pathPrefix = "/idp/profile/oauth/status-list/list";
+
+ /** Expiration shared by credentials issued. */
+ @Nullable
+ private Instant expiration;
+
+ /** Number of credentials being issued. */
+ private int count;
/**
* Set the status list index allocator.
@@ -72,7 +98,28 @@ public class AllocateIndex extends AbstractProfileAction {
}
/**
- * Set the path prefix prepended to the allocated {@code uriIndex}.
+ * Set the strategy used to obtain the expiration shared by the credentials
+ * issued.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setExpirationLookupStrategy(@Nonnull final Function<ProfileRequestContext, Instant> strategy) {
+ checkSetterPreconditions();
+ expirationLookupStrategy = Constraint.isNotNull(strategy, "Expiration lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to obtain the number of credentials being issued.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setIndexCountLookupStrategy(@Nonnull final Function<ProfileRequestContext, Integer> strategy) {
+ checkSetterPreconditions();
+ indexCountLookupStrategy = Constraint.isNotNull(strategy, "Index count lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the path prefix prepended to the bucket identifier.
*
* @param prefix Path prefix to use.
*/
@@ -88,6 +135,12 @@ public class AllocateIndex extends AbstractProfileAction {
if (statusListAllocator == null) {
throw new ComponentInitializationException("StatusListIndexAllocator cannot be null");
}
+ if (expirationLookupStrategy == null) {
+ throw new ComponentInitializationException("Expiration lookup strategy cannot be null");
+ }
+ if (indexCountLookupStrategy == null) {
+ throw new ComponentInitializationException("Index count lookup strategy cannot be null");
+ }
}
/** {@inheritDoc} */
@@ -101,6 +154,22 @@ public class AllocateIndex extends AbstractProfileAction {
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
return false;
}
+
+ expiration = expirationLookupStrategy.apply(profileRequestContext);
+ if (expiration == null) {
+ log.error("{} Unable to determine credential expiration to choose a status list", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ final Integer requested = indexCountLookupStrategy.apply(profileRequestContext);
+ if (requested == null || requested < 1) {
+ log.error("{} Unable to determine how many status list slots to allocate", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ count = requested;
+
return true;
}
@@ -115,11 +184,14 @@ public class AllocateIndex extends AbstractProfileAction {
return;
}
- final StatusListIndex slot;
+ assert expiration != null;
+ final StatusListBucket bucket = bucketStrategy.apply(expiration);
+
+ final List<Long> indices;
try {
- slot = statusListAllocator.allocate();
+ indices = statusListAllocator.allocate(bucket, count);
} catch (final IOException e) {
- log.error("{} Failed to allocate status list slot: {}", getLogPrefix(), e.getMessage());
+ log.error("{} Failed to allocate status list slots: {}", getLogPrefix(), e.getMessage());
ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
return;
}
@@ -131,8 +203,8 @@ public class AllocateIndex extends AbstractProfileAction {
assert scheme != null;
final String serverName = request.getServerName();
assert serverName != null;
- statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(),
- pathPrefix + "/" + slot.uriIndex());
+ statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(), pathPrefix,
+ StatusListParameters.BUCKET_PARAMETER + "=" + bucket.id());
} catch (final URISyntaxException e) {
log.error("{} Unable to build status list URI", getLogPrefix(), e);
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
@@ -140,11 +212,11 @@ public class AllocateIndex extends AbstractProfileAction {
}
final StatusListContext ctx = outbound.ensureSubcontext(StatusListContext.class);
- ctx.setIndex(slot.index());
- ctx.setStatusListUrlPath(statusListUri.toString());
+ ctx.setStatusListUri(statusListUri.toString());
+ ctx.setIndices(indices);
- log.debug("{} Allocated status list slot uriIndex={} index={} uri={}", getLogPrefix(), slot.uriIndex(),
- slot.index(), statusListUri);
+ log.debug("{} Allocated {} status list slot(s) {} from bucket {} (retirable after {}) at uri {}",
+ getLogPrefix(), count, indices, bucket.id(), bucket.end(), statusListUri);
}
/**
@@ -155,14 +227,15 @@ public class AllocateIndex extends AbstractProfileAction {
* @param host host name
* @param port server port
* @param path path component
+ * @param query query 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 {
+ @Nonnull final String path, @Nonnull final String query) 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);
+ return new URI(scheme, null, host, defaultPort ? -1 : port, path, query, null);
}
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java
new file mode 100644
index 0000000..d418df3
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java
@@ -0,0 +1,167 @@
+/*
+ * 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 java.time.Instant;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignment;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action that records the slots allocated by {@link AllocateIndex}, to be collected
+ * by a party outside the IdP.
+ *
+ * Failing to write the record fails the issuance.
+ */
+public class AssignIndex extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(AssignIndex.class);
+
+ /** Store handing records to a collector. */
+ @NonnullAfterInit
+ private StatusListAssignmentStore statusListAssignmentStore;
+
+ /** Strategy used to obtain the expiration of what took the slots. */
+ @NonnullAfterInit
+ private Function<ProfileRequestContext, Instant> expirationLookupStrategy;
+
+ /** Strategy used to obtain a description of what took the slots. */
+ @NonnullAfterInit
+ private Function<ProfileRequestContext, Map<String, Object>> detailsLookupStrategy;
+
+ /** Status list context. */
+ @NonnullBeforeExec
+ private StatusListContext statusListContext;
+
+ /** Expiration of what took the slots. */
+ @Nullable
+ private Instant expiration;
+
+ /**
+ * Set the store handing records to a collector.
+ *
+ * @param store The store to use.
+ */
+ public void setStatusListAssignmentStore(@Nonnull final StatusListAssignmentStore store) {
+ checkSetterPreconditions();
+ statusListAssignmentStore = Constraint.isNotNull(store, "StatusListAssignmentStore cannot be null");
+ }
+
+ /**
+ * Set the strategy used to obtain the expiration of what took slots.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setExpirationLookupStrategy(@Nonnull final Function<ProfileRequestContext, Instant> strategy) {
+ checkSetterPreconditions();
+ expirationLookupStrategy = Constraint.isNotNull(strategy, "Expiration lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to obtain a description of what took slots.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setDetailsLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, Map<String, Object>> strategy) {
+ checkSetterPreconditions();
+ detailsLookupStrategy = Constraint.isNotNull(strategy, "Details lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ if (statusListAssignmentStore == null) {
+ throw new ComponentInitializationException("StatusListAssignmentStore cannot be null");
+ }
+ if (expirationLookupStrategy == null) {
+ throw new ComponentInitializationException("Expiration lookup strategy cannot be null");
+ }
+ if (detailsLookupStrategy == null) {
+ throw new ComponentInitializationException("Details lookup strategy cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ if (profileRequestContext.getOutboundMessageContext() != null) {
+ statusListContext = profileRequestContext.getOutboundMessageContext()
+ .getSubcontext(StatusListContext.class);
+ }
+ if (statusListContext == null || statusListContext.getIndices() == null) {
+ log.error("{} No allocated slots to record", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ expiration = expirationLookupStrategy.apply(profileRequestContext);
+ if (expiration == null) {
+ log.error("{} Unable to determine the expiration of what took the slots", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ assert expiration != null;
+
+ final StatusListAssignment record = new StatusListAssignment();
+ record.setId(new MonthlyBucketStrategy().apply(expiration).id());
+ record.setIndices(statusListContext.getIndices());
+ record.setExpires(expiration.getEpochSecond());
+ record.setDetails(detailsLookupStrategy.apply(profileRequestContext));
+
+ try {
+ statusListAssignmentStore.store(record);
+ } catch (final IOException e) {
+ log.error("{} Unable to record the assignment, failing the request: {}", getLogPrefix(), e.getMessage());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
new file mode 100644
index 0000000..8ae8607
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
@@ -0,0 +1,116 @@
+/*
+ * 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 javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsRequest;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsSuccessResponse;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignments;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action reading a page of records of status list assignments and forming
+ * {@link StatusListAssignmentsSuccessResponse}.
+ *
+ */
+public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(FormOutboundStatusListAssignmentsResponseMessage.class);
+
+ /** Store holding the records. */
+ @NonnullAfterInit
+ private StatusListAssignmentStore statusListAssignmentStore;
+
+ /** Request being served. */
+ @NonnullBeforeExec
+ private StatusListAssignmentsRequest request;
+
+ /**
+ * Set store holding the records.
+ *
+ * @param store store to use.
+ */
+ public void setStatusListAssignmentStore(@Nonnull final StatusListAssignmentStore store) {
+ checkSetterPreconditions();
+ statusListAssignmentStore = Constraint.isNotNull(store, "StatusListAssignmentStore cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ if (statusListAssignmentStore == null) {
+ throw new ComponentInitializationException("StatusListAssignmentStore cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ if (profileRequestContext.getInboundMessageContext() == null || !(profileRequestContext
+ .getInboundMessageContext().getMessage() instanceof final StatusListAssignmentsRequest message)) {
+ log.error("{} No StatusListAssignmentsRequest as inbound message", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+ return false;
+ }
+ request = message;
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ assert request != null;
+
+ final StatusListAssignments records;
+ try {
+ records = statusListAssignmentStore.read(request.getFrom(), request.getLimit());
+ } catch (final IOException e) {
+ log.error("{} Unable to read records of status list assignments: {}", getLogPrefix(), e.getMessage());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return;
+ }
+
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new StatusListAssignmentsSuccessResponse(records));
+
+ log.debug("{} Returning {} record(s), continuing at {} of {}", getLogPrefix(), records.getRecords().size(),
+ records.getNext(), records.getHighWater());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
new file mode 100644
index 0000000..4ecd2ea
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
@@ -0,0 +1,102 @@
+/*
+ * 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.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListSuccessResponse;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action forming {@link StatusListSuccessResponse} from the signed token in
+ * {@link StatusListTokenContext#getSignedToken}.
+ */
+public class FormOutboundStatusListResponseMessage extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(FormOutboundStatusListResponseMessage.class);
+
+ /** Status list token context. */
+ @NonnullBeforeExec
+ private StatusListTokenContext ctx;
+
+ /** Strategy used to locate the status list token context. */
+ @Nonnull
+ private Function<ProfileRequestContext, StatusListTokenContext> statusListTokenContextLookupStrategy;
+
+ /** Constructor. */
+ public FormOutboundStatusListResponseMessage() {
+ final Function<ProfileRequestContext, StatusListTokenContext> tlcs = new ChildContextLookup<>(
+ StatusListTokenContext.class).compose(new OutboundMessageContextLookup());
+ assert tlcs != null;
+ statusListTokenContextLookupStrategy = tlcs;
+ }
+
+ /**
+ * Set the strategy used to locate the {@link StatusListTokenContext} to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setStatusListTokenContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, StatusListTokenContext> strategy) {
+ checkSetterPreconditions();
+ statusListTokenContextLookupStrategy = Constraint.isNotNull(strategy,
+ "StatusListTokenContext lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ ctx = statusListTokenContextLookupStrategy.apply(profileRequestContext);
+ if (ctx == null || ctx.getSignedToken() == null) {
+ log.error("{} No signed status list token to return", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new StatusListSuccessResponse(ctx.getSignedToken()));
+
+ log.debug("{} Returning a status list token of {} characters", getLogPrefix(), ctx.getSignedToken().length());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java
new file mode 100644
index 0000000..2c08bdd
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java
@@ -0,0 +1,37 @@
+/*
+ * 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 javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeSuccessResponse;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+
+/**
+ * Action forming {@link StatusListRevokeSuccessResponse}.
+ */
+public class FormOutboundStatusListRevokeResponseMessage extends AbstractProfileAction {
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ profileRequestContext.ensureOutboundMessageContext().setMessage(new StatusListRevokeSuccessResponse());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java
new file mode 100644
index 0000000..4a6256b
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java
@@ -0,0 +1,214 @@
+/*
+ * 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 java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore;
+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 com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+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;
+
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
+
+/**
+ * Action that forms claims of a Status List Token and sets them to
+ * {@link StatusListTokenContext} located under
+ * {@link ProfileRequestContext#getOutboundMessageContext()}, for a subsequent
+ * action to sign.
+ */
+public class FormStatusListToken extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(FormStatusListToken.class);
+
+ /** Strategy used to obtain the issuer value. */
+ @Nonnull
+ private Function<ProfileRequestContext, String> issuerLookupStrategy;
+
+ /** Store of the published status lists. */
+ @NonnullAfterInit
+ private StatusListStore statusListStore;
+
+ /** How long a published list may be cached. */
+ @Nonnull
+ private Duration ttl = Duration.ofHours(1);
+
+ /** Compressed and encoded status list with no slot revoked. */
+ @NonnullAfterInit
+ private String emptyList;
+
+ /** Issuer of the token. */
+ @Nullable
+ private String issuerId;
+
+ /** The list being published. */
+ @Nullable
+ private StatusListBucket bucket;
+
+ /** Constructor. */
+ public FormStatusListToken() {
+ // TODO: Token must name the same issuer as the credentials referring to it.
+ // With did:jwk key rollover will need us to redesign things.
+ issuerLookupStrategy = new CredentialIssuerLookupFunction();
+ }
+
+ /**
+ * Set the strategy used to locate the issuer value to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setIssuerLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+ checkSetterPreconditions();
+ issuerLookupStrategy = Constraint.isNotNull(strategy, "IssuerLookupStrategy cannot be null");
+ }
+
+ /**
+ * Set the store of the published status lists.
+ *
+ * @param store The store to use.
+ */
+ public void setStatusListStore(@Nonnull final StatusListStore store) {
+ checkSetterPreconditions();
+ statusListStore = Constraint.isNotNull(store, "StatusListStore cannot be null");
+ }
+
+ /**
+ * Set how long a published list may be cached.
+ *
+ * Also the delay before a revocation is noticed. A verifier with a cached list
+ * does not see it before the cache expires.
+ *
+ * @param cacheLifetime positive lifetime
+ */
+ public void setTtl(@Nonnull final Duration cacheLifetime) {
+ checkSetterPreconditions();
+ Constraint.isNotNull(cacheLifetime, "Ttl cannot be null");
+ Constraint.isFalse(cacheLifetime.isNegative() || cacheLifetime.isZero(), "Ttl must be positive");
+ ttl = cacheLifetime;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ if (statusListStore == null) {
+ throw new ComponentInitializationException("StatusListStore cannot be null");
+ }
+ emptyList = StatusListCodec.encode(StatusListCodec.empty());
+ log.debug("{} Status list of {} slots with none revoked encodes to {} characters", getLogPrefix(),
+ StatusListParameters.CAPACITY, emptyList.length());
+ }
+
+ /** {@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;
+ }
+ if (profileRequestContext.getOutboundMessageContext() == null) {
+ log.error("{} No outbound message context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ issuerId = issuerLookupStrategy.apply(profileRequestContext);
+ if (issuerId == null) {
+ log.error("{} Unable to determine issuer of the status list token", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
+
+ final String bucketId = getHttpServletRequest().getParameter(StatusListParameters.BUCKET_PARAMETER);
+ bucket = MonthlyBucketStrategy.parse(bucketId);
+ if (bucket == null) {
+ log.error("{} '{}' in parameter '{}' does not name a status list", getLogPrefix(), bucketId,
+ StatusListParameters.BUCKET_PARAMETER);
+ ActionSupport.buildEvent(profileRequestContext, StatusListEventIds.NO_STATUS_LIST);
+ return false;
+ }
+ if (!bucket.end().isAfter(Instant.now())) {
+ log.info("{} Status list {} ended at {} and is no longer published", getLogPrefix(), bucket.id(),
+ bucket.end());
+ ActionSupport.buildEvent(profileRequestContext, StatusListEventIds.STATUS_LIST_RETIRED);
+ return false;
+ }
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ assert bucket != null;
+
+ final String list;
+ try {
+ final String stored = statusListStore.read(bucket);
+ list = stored == null ? emptyList : stored;
+ } catch (final IOException e) {
+ log.error("{} Unable to read status list {}: {}", getLogPrefix(), bucket.id(), e.getMessage());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return;
+ }
+ final String subject = getHttpServletRequest().getRequestURL().append('?')
+ .append(StatusListParameters.BUCKET_PARAMETER).append('=').append(bucket.id()).toString();
+ final JWTClaimsSet claims = new JWTClaimsSet.Builder().issuer(issuerId).subject(subject)
+ .issueTime(Date.from(Instant.now())).expirationTime(Date.from(bucket.end()))
+ .claim("ttl", ttl.toSeconds())
+ .claim("status_list", Map.of("bits", StatusListParameters.STATUS_BITS, "lst", list)).build();
+
+ final MessageContext outbound = profileRequestContext.getOutboundMessageContext();
+ assert outbound != null;
+ outbound.ensureSubcontext(StatusListTokenContext.class).setClaims(claims);
+
+ log.debug("{} Formed status list token for {}", getLogPrefix(), subject);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java
new file mode 100644
index 0000000..09ba87a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java
@@ -0,0 +1,129 @@
+/*
+ * 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 javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeRequest;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that revokes the slot of a {@link StatusListRevokeRequest} in the
+ * stored status list.
+ *
+ * List is created at the first revocation. Revoking already revoked slot
+ * succeeds without writing.
+ *
+ */
+public class RevokeStatusListIndex extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(RevokeStatusListIndex.class);
+
+ /** Store of the published status lists. */
+ @NonnullAfterInit
+ private StatusListStore statusListStore;
+
+ /** The list the slot belongs to. */
+ @NonnullBeforeExec
+ private StatusListBucket bucket;
+
+ /** The slot to revoke. */
+ private long index;
+
+ /**
+ * Set the store of the published status lists.
+ *
+ * @param store The store to use.
+ */
+ public void setStatusListStore(@Nonnull final StatusListStore store) {
+ checkSetterPreconditions();
+ statusListStore = Constraint.isNotNull(store, "StatusListStore cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ if (statusListStore == null) {
+ throw new ComponentInitializationException("StatusListStore cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ if (profileRequestContext.getInboundMessageContext() == null || !(profileRequestContext
+ .getInboundMessageContext().getMessage() instanceof final StatusListRevokeRequest request)) {
+ log.error("{} No StatusListRevokeRequest as inbound message", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+ return false;
+ }
+
+ bucket = MonthlyBucketStrategy.parse(request.getId());
+ if (bucket == null) {
+ log.error("{} '{}' does not name a status list", getLogPrefix(), request.getId());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+ return false;
+ }
+
+ index = request.getIndex();
+ if (index < 0 || index >= StatusListParameters.CAPACITY) {
+ log.error("{} Slot {} lies outside a status list of {}", getLogPrefix(), index,
+ StatusListParameters.CAPACITY);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+ return false;
+ }
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ assert bucket != null;
+ try {
+ statusListStore.revoke(bucket, index);
+ } catch (final IOException e) {
+ log.error("{} Failed to revoke slot {} of status list {}: {}", getLogPrefix(), index, bucket.id(),
+ e.getMessage());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
new file mode 100644
index 0000000..ce21ffb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
@@ -0,0 +1,224 @@
+/*
+ * 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.security.interfaces.ECPrivateKey;
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that signs claims in {@link StatusListTokenContext#getClaims} and
+ * stores the result to {@link StatusListTokenContext#setSignedToken}.
+ *
+ * Same as SignJWTCredential of the credentials flow, including support only for
+ * EC family only. Sets the statuslist+jwt type header required of the token.
+ */
+public class SignStatusListToken extends AbstractProfileAction {
+
+ /** Type header of a status list token. */
+ @Nonnull
+ private static final String TOKEN_TYPE = "statuslist+jwt";
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(SignStatusListToken.class);
+
+ /** Status list token context. */
+ @NonnullBeforeExec
+ private StatusListTokenContext ctx;
+
+ /**
+ * Strategy used to locate the {@link SecurityParametersContext} to use for
+ * signing.
+ */
+ @Nonnull
+ private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+ /** Strategy used to obtain the certificate chain for signing key. */
+ @Nonnull
+ private Function<SignatureSigningParameters, List<Base64>> certificateChainLookupStrategy;
+
+ /** Strategy used to locate the status list token context. */
+ @Nonnull
+ private Function<ProfileRequestContext, StatusListTokenContext> statusListTokenContextLookupStrategy;
+
+ /** The signature signing parameters. */
+ @NonnullAfterInit
+ private SignatureSigningParameters signatureSigningParameters;
+
+ /** Signing credential. */
+ @NonnullAfterInit
+ private Credential signingCredential;
+
+ /** EC private key for signing. */
+ @NonnullAfterInit
+ private ECPrivateKey privateKey;
+
+ /** Constructor. */
+ public SignStatusListToken() {
+ final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+ SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+ assert splcs != null;
+ securityParametersLookupStrategy = splcs;
+
+ final Function<ProfileRequestContext, StatusListTokenContext> tlcs = new ChildContextLookup<>(
+ StatusListTokenContext.class).compose(new OutboundMessageContextLookup());
+ assert tlcs != null;
+ statusListTokenContextLookupStrategy = tlcs;
+
+ certificateChainLookupStrategy = FunctionSupport.constant(null);
+ }
+
+ /**
+ * Set the strategy used to locate the {@link SecurityParametersContext} to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setSecurityParametersLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+ checkSetterPreconditions();
+ securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+ "SecurityParameterContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to locate the {@link StatusListTokenContext} to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setStatusListTokenContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, StatusListTokenContext> strategy) {
+ checkSetterPreconditions();
+ statusListTokenContextLookupStrategy = Constraint.isNotNull(strategy,
+ "StatusListTokenContext lookup strategy cannot be null");
+ }
+
+ /**
+ * Set the strategy used to obtain the certificate chain for the signing key.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setCertificateChainLookupStrategy(
+ @Nonnull final Function<SignatureSigningParameters, List<Base64>> strategy) {
+ checkSetterPreconditions();
+ certificateChainLookupStrategy = Constraint.isNotNull(strategy,
+ "CertificateChainLookupStrategy lookup strategy cannot be null");
+ }
+
+ /**
+ * Get the signing parameters to apply.
+ *
+ * @return the signing parameters
+ */
+ @Nullable
+ public SignatureSigningParameters getSignatureSigningParameters() {
+ return signatureSigningParameters;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ ctx = statusListTokenContextLookupStrategy.apply(profileRequestContext);
+ if (ctx == null || ctx.getClaims() == null) {
+ log.error("{} No status list token to sign", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+ final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+ if (secParamCtx == null) {
+ log.debug("{} no security parameters context is available", getLogPrefix());
+ return false;
+ }
+
+ signatureSigningParameters = secParamCtx.getSignatureSigningParameters();
+ if (signatureSigningParameters == null) {
+ log.debug("{} no signature signing parameters available", getLogPrefix());
+ return false;
+ }
+ signingCredential = signatureSigningParameters.getSigningCredential();
+ if (signingCredential == null) {
+ log.debug("{} no signature signing credential available", getLogPrefix());
+ return false;
+ }
+ if (signingCredential.getPrivateKey() instanceof final ECPrivateKey ecKey) {
+ privateKey = ecKey;
+ return true;
+ }
+ log.error("{} No EC private key as signing parameter", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+ return false;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ try {
+ // For now we support only EC family.
+ final SignedJWT signedJWT = new SignedJWT(
+ new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
+ .x509CertChain(certificateChainLookupStrategy.apply(signatureSigningParameters))
+ .type(new JOSEObjectType(TOKEN_TYPE))
+ .keyID(CredentialConversionUtil.resolveKid(signingCredential)).build(),
+ ctx.getClaims());
+
+ signedJWT.sign(new ECDSASigner(privateKey));
+ ctx.setSignedToken(signedJWT.serialize());
+
+ log.debug("{} Signed status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
+ } catch (final JOSEException e) {
+ log.error("{} Signing status list token failed", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java
new file mode 100644
index 0000000..2d8e418
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java
@@ -0,0 +1,122 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Keyed permutation of the slot space as described in
+ * https://en.wikipedia.org/wiki/Feistel_cipher and sources linked in that.
+ *
+ * Idea is to walk through range of values in unpredictable order.
+ *
+ * TODO: further verification this works as intended.
+ */
+public class FeistelIndexPermutation {
+
+ /** Feistel round count. Ten as in FF1 of NIST SP 800-38G. */
+ private static final int ROUNDS = 10;
+
+ /** Digest algorithm backing the round function. */
+ @Nonnull
+ private static final String DIGEST = "SHA-256";
+
+ /** Width in bits of each half of the permuted value. */
+ private final int halfBits;
+
+ /** Mask retaining one half of the permuted value. */
+ private final int halfMask;
+
+ /** Key material for the round function. */
+ @Nonnull
+ private final byte[] key;
+
+ /**
+ * Constructor.
+ *
+ * @param bits width of the permuted space, even and between 8 and 32
+ * @param keyMaterial key for the round function
+ */
+ public FeistelIndexPermutation(final int bits, @Nonnull final byte[] keyMaterial) {
+ Constraint.isTrue(bits >= 8 && bits <= 32, "Permutation width must be between 8 and 32 bits");
+ Constraint.isTrue(bits % 2 == 0, "Permutation width must be even");
+ Constraint.isNotNull(keyMaterial, "Permutation key cannot be null");
+ Constraint.isTrue(keyMaterial.length > 0, "Permutation key cannot be empty");
+
+ halfBits = bits / 2;
+ halfMask = (1 << halfBits) - 1;
+ key = keyMaterial.clone();
+ }
+
+ /**
+ * Permute a counter value into an index.
+ *
+ * @param counter value to permute, less than {@code 2^bits}
+ * @param tweak value separating one bucket's permutation from another's
+ * @return permuted index
+ */
+ public long permute(final long counter, @Nonnull final String tweak) {
+ Constraint.isNotNull(tweak, "Tweak cannot be null");
+
+ int left = (int) ((counter >>> halfBits) & halfMask);
+ int right = (int) (counter & halfMask);
+
+ for (int round = 0; round < ROUNDS; round++) {
+ final int next = left ^ roundFunction(tweak, round, right);
+ left = right;
+ right = next;
+ }
+
+ return ((long) left << halfBits) | right;
+ }
+
+ /**
+ * Keyed round function, reduced to one half of the permuted space.
+ *
+ * @param tweak value separating one bucket's permutation from another's
+ * @param round round ordinal
+ * @param value half to mix
+ * @return pseudorandom value of {@code halfBits} width
+ */
+ private int roundFunction(@Nonnull final String tweak, final int round, final int value) {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(DIGEST);
+ } catch (final NoSuchAlgorithmException e) {
+ // SHA-256 is required of every Java runtime.
+ throw new IllegalStateException(DIGEST + " unavailable", e);
+ }
+
+ digest.update(key);
+ digest.update(tweak.getBytes(StandardCharsets.UTF_8));
+ digest.update((byte) round);
+ digest.update(new byte[] { (byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value });
+
+ final byte[] hash = digest.digest();
+ final int mixed = ((hash[0] & 0xff) << 24) | ((hash[1] & 0xff) << 16) | ((hash[2] & 0xff) << 8)
+ | (hash[3] & 0xff);
+
+ return mixed & halfMask;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java
new file mode 100644
index 0000000..63a19ca
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java
@@ -0,0 +1,183 @@
+/*
+ * 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.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Record of status list slots assigned to something, to be collected by a party
+ * outside the IdP.
+ *
+ * A status list holds only bits, so without this record there is no way to find
+ * the slots to revoke. Nothing in the IdP reads the record back.
+ *
+ * {@link #getDetails()} describes what took the slots. It is given by the
+ * issuing protocol and not interpreted here.
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+public class StatusListAssignment {
+
+ /** Position of the record in the sequence of records, its key. */
+ @JsonProperty("seq")
+ private long seq;
+
+ /** Identifier of the status list the slots belong to. */
+ @JsonProperty("id")
+ private String id;
+
+ /** Slots of the status list assigned. */
+ @JsonProperty("idx")
+ private List<Long> indices;
+
+ /** Expiration of what took the slots, in seconds since the epoch. */
+ @JsonProperty("expires")
+ private long expires;
+
+ /** What took the slots, as the issuing protocol describes it. */
+ @JsonProperty("details")
+ private Map<String, Object> details;
+
+ /** Constructor. */
+ public StatusListAssignment() {
+ }
+
+ /**
+ * Get position of the record in the sequence of records.
+ *
+ * @return Position of the record in the sequence of records
+ */
+ public long getSeq() {
+ return seq;
+ }
+
+ /**
+ * Set position of the record in the sequence of records.
+ *
+ * @param position Position of the record in the sequence of records
+ */
+ public void setSeq(final long position) {
+ seq = position;
+ }
+
+ /**
+ * Get identifier of the status list the slots belong to.
+ *
+ * @return Identifier of the status list the slots belong to
+ */
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Set identifier of the status list the slots belong to.
+ *
+ * @param listId Identifier of the status list the slots belong to
+ */
+ public void setId(@Nullable final String listId) {
+ id = listId;
+ }
+
+ /**
+ * Get slots of the status list assigned.
+ *
+ * @return Slots of the status list assigned
+ */
+ @Nullable
+ public List<Long> getIndices() {
+ return indices;
+ }
+
+ /**
+ * Set slots of the status list assigned.
+ *
+ * @param slots Slots of the status list assigned
+ */
+ public void setIndices(@Nullable final List<Long> slots) {
+ indices = slots;
+ }
+
+ /**
+ * Get expiration of what took the slots, in seconds since the epoch.
+ *
+ * @return Expiration of what took the slots
+ */
+ public long getExpires() {
+ return expires;
+ }
+
+ /**
+ * Set expiration of what took the slots, in seconds since the epoch.
+ *
+ * @param expiration Expiration of what took the slots
+ */
+ public void setExpires(final long expiration) {
+ expires = expiration;
+ }
+
+ /**
+ * Get what took the slots, as the issuing protocol describes it.
+ *
+ * @return What took the slots
+ */
+ @Nullable
+ public Map<String, Object> getDetails() {
+ return details;
+ }
+
+ /**
+ * Set what took the slots, as the issuing protocol describes it.
+ *
+ * @param protocolDetails What took the slots
+ */
+ public void setDetails(@Nullable final Map<String, Object> protocolDetails) {
+ details = protocolDetails;
+ }
+
+ /**
+ * Serialize the record to json.
+ *
+ * @return the record as json
+ * @throws JsonProcessingException if serialization fails
+ */
+ @Nonnull
+ public String serialize() throws JsonProcessingException {
+ return new ObjectMapper().writeValueAsString(this);
+ }
+
+ /**
+ * Parse a record from json.
+ *
+ * @param record the record as json
+ * @return parsed record
+ * @throws JsonProcessingException if parsing fails
+ */
+ @Nonnull
+ public static StatusListAssignment parse(@Nonnull final String record) throws JsonProcessingException {
+ return new ObjectMapper().readValue(record, StatusListAssignment.class);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java
new file mode 100644
index 0000000..35d9b1a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java
@@ -0,0 +1,261 @@
+/*
+ * 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 java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+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.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;
+
+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.
+ */
+ at ThreadSafeAfterInit
+public class StatusListAssignmentStore extends AbstractIdentifiableInitializableComponent {
+
+ /** Logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(StatusListAssignmentStore.class);
+
+ /** Storage context for the records. */
+ @Nonnull
+ @NotEmpty
+ private static final String CONTEXT = StatusListAssignmentStore.class.getName();
+
+ /**
+ * Storage key of the sequence, which no record can take, records being numbers.
+ */
+ @Nonnull
+ @NotEmpty
+ private static final String KEY_SEQUENCE = "sequence";
+
+ /** Bounded retry cap for optimistic-update contention. */
+ private static final int MAX_RETRIES = 32;
+
+ /** Positions probed per record asked for, bounding the work of one read. */
+ private static final int PROBE_FACTOR = 10;
+
+ /** How long a record waits to be collected. */
+ @Nonnull
+ private Duration retention = Duration.ofDays(7);
+
+ /** Backing storage for the records. */
+ private StorageService storage;
+
+ /**
+ * Get the backing store.
+ *
+ * @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 how long a record waits to be collected.
+ *
+ * @return retention of a record
+ */
+ @Nonnull
+ public Duration getRetention() {
+ return retention;
+ }
+
+ /**
+ * Set how long a record waits to be collected.
+ *
+ * Long enough that a collector being down does not lose the possibility to
+ * revoke what was issued in the meantime.
+ *
+ * @param recordRetention positive retention
+ */
+ public void setRetention(@Nonnull final Duration recordRetention) {
+ checkSetterPreconditions();
+ Constraint.isNotNull(recordRetention, "Retention cannot be null");
+ Constraint.isFalse(recordRetention.isNegative() || recordRetention.isZero(), "Retention must be positive");
+ retention = recordRetention;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (storage == null) {
+ throw new ComponentInitializationException("StorageService cannot be null");
+ }
+ log.info("Records of status list assignments are kept {} for collection", retention);
+ }
+
+ /**
+ * Store a record of an assignment.
+ *
+ * The record takes the next position of the sequence.
+ *
+ * @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
+ */
+ public void store(@Nonnull final StatusListAssignment record) throws IOException {
+ checkComponentActive();
+ Constraint.isNotNull(record, "Record cannot be null");
+
+ final long seq = nextSequence();
+ 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, Instant.now().plus(retention).toEpochMilli())) {
+ throw new IOException("Record of a status list assignment was not stored at " + seq);
+ }
+
+ log.debug("Stored record {} of {} slot(s) of status list {}", seq,
+ record.getIndices() == null ? 0 : record.getIndices().size(), record.getId());
+ }
+
+ /**
+ * Read a page of records.
+ *
+ * Positions are read one by one and an empty position is skipped, an expired
+ * record leaves a gap. The number of positions read is bounded, so a collector
+ * that is far behind gets one page at a time.
+ *
+ * @param from position to read from
+ * @param limit most records to return
+ * @return the records read, and where to continue
+ * @throws IOException if the storage backend errors
+ */
+ @Nonnull
+ public StatusListAssignments read(final long from, final int limit) throws IOException {
+ checkComponentActive();
+ Constraint.isTrue(from >= 0, "Position to read from cannot be negative");
+ Constraint.isGreaterThan(0, limit, "Limit must be positive");
+
+ final long highWater = currentSequence();
+ final long ceiling = Math.min(highWater, from + (long) limit * PROBE_FACTOR);
+
+ final List<StatusListAssignment> records = new ArrayList<>();
+ long seq = from;
+ while (seq < ceiling && records.size() < limit) {
+ final StorageRecord<String> stored = storage.read(CONTEXT, Long.toString(seq));
+ if (stored != null) {
+ try {
+ records.add(StatusListAssignment.parse(stored.getValue()));
+ } catch (final JsonProcessingException e) {
+ throw new IOException("Unable to parse record of a status list assignment at " + seq, e);
+ }
+ }
+ seq++;
+ }
+
+ log.debug("Read {} record(s) from {}, continuing at {} of {}", records.size(), from, seq, highWater);
+
+ return new StatusListAssignments(records, seq, highWater);
+ }
+
+ /**
+ * Get the position beyond the last record written.
+ *
+ * @return position beyond the last record written
+ * @throws IOException if the storage backend errors
+ */
+ private long currentSequence() throws IOException {
+ final StorageRecord<String> record = storage.read(CONTEXT, KEY_SEQUENCE);
+
+ return record == null ? 0L : Long.parseLong(record.getValue());
+ }
+
+ /**
+ * Take the next position in the sequence.
+ *
+ * @return position taken
+ * @throws IOException if the storage backend errors, or if contention could not
+ * be resolved within {@value #MAX_RETRIES} attempts
+ */
+ private long nextSequence() 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;
+ }
+ } 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);
+ }
+ }
+ }
+
+ throw new IOException(
+ "Failed to take a record position after " + MAX_RETRIES + " attempts (storage contention).");
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java
new file mode 100644
index 0000000..271e136
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java
@@ -0,0 +1,104 @@
+/*
+ * 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.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * A page of {@link StatusListAssignment} records for a collector.
+ *
+ * The collector stores {@link #getNext()} and gives it back to continue. No state of
+ * a reader is kept here. {@link #getHighWater()} tells how far the records go, so a
+ * collector knows when it is up to date.
+ *
+ * Gaps in the sequence are normal.
+ */
+public class StatusListAssignments {
+
+ /** The records read. */
+ @Nonnull
+ @JsonProperty("records")
+ private final List<StatusListAssignment> records;
+
+ /** Position to continue from. */
+ @JsonProperty("next")
+ private final long next;
+
+ /** Position beyond the last record written. */
+ @JsonProperty("high_water")
+ private final long highWater;
+
+ /**
+ * Constructor.
+ *
+ * @param read the records read
+ * @param nextSeq position to continue from
+ * @param written position beyond the last record written
+ */
+ public StatusListAssignments(@Nonnull final List<StatusListAssignment> read, final long nextSeq,
+ final long written) {
+ records = List.copyOf(read);
+ next = nextSeq;
+ highWater = written;
+ }
+
+ /**
+ * Get the records read.
+ *
+ * @return The records read
+ */
+ @Nonnull
+ public List<StatusListAssignment> getRecords() {
+ return records;
+ }
+
+ /**
+ * Get position to continue from.
+ *
+ * @return Position to continue from
+ */
+ public long getNext() {
+ return next;
+ }
+
+ /**
+ * Get position beyond the last record written.
+ *
+ * @return Position beyond the last record written
+ */
+ public long getHighWater() {
+ return highWater;
+ }
+
+ /**
+ * Serialize the page to json.
+ *
+ * @return the page as json
+ * @throws JsonProcessingException if serialization fails
+ */
+ @Nonnull
+ public String serialize() throws JsonProcessingException {
+ return new ObjectMapper().writeValueAsString(this);
+ }
+
+}
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
index 79a0c14..808c5f8 100644
--- 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
@@ -17,6 +17,10 @@
package org.geant.shibboleth.plugin.oauth.statuslist.storage;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import javax.annotation.Nonnull;
@@ -27,7 +31,8 @@ import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponen
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
import org.opensaml.storage.StorageCapabilities;
import org.opensaml.storage.StorageRecord;
import org.opensaml.storage.StorageService;
@@ -36,11 +41,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Allocator producing increasing {@link StatusListIndex} values, backed by
- * Shibboleth {@link StorageService}.
+ * Allocator of status list slots, backed by Shibboleth {@link StorageService}.
*
- * Records use no expiration. Memory-backed StorageService is not
- * suitable for real deployment.
+ * State is one counter per {@link StatusListBucket} stored under the bucket
+ * identifier. Counter values are mapped to indices with a keyed permutation
+ * (order cannot be read from an issued credential( and slots allocated together
+ * are not consecutive. A bucket has {@value #CAPACITY} slots.
+ *
+ * A record expires after the end of the bucket.
*/
@ThreadSafeAfterInit
public class StatusListIndexAllocator extends AbstractIdentifiableInitializableComponent {
@@ -54,21 +62,39 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
@NotEmpty
private static final String CONTEXT = StatusListIndexAllocator.class.getName();
- /** Storage key for the packed counter. */
+ /** Bounded retry cap for optimistic-update contention. */
+ private static final int MAX_RETRIES = 32;
+
+ /**
+ * Permutation key shipped in the plugin's properties, to be replaced on
+ * install.
+ */
@Nonnull
@NotEmpty
- private static final String KEY_COUNTER = "counter";
+ private static final String PLACEHOLDER_KEY = "change_this_to_secret";
- /** Delimeter between {@code uriIndex} and {@code index} in the packed value. */
+ /** Width in bits of the slot space of a single bucket. */
+ private static final int INDEX_BITS = StatusListParameters.INDEX_BITS;
+
+ /** Number of slots in a single bucket. */
+ private static final long CAPACITY = StatusListParameters.CAPACITY;
+
+ /**
+ * Time the counter of a bucket is kept after the end of the bucket. Losing it
+ * earlier restarts it from zero and the same slots are issued again.
+ */
@Nonnull
- @NotEmpty
- private static final String DEL = ":";
+ private static final Duration RETENTION = StatusListParameters.RETENTION;
- /** Bounded retry cap for optimistic-update contention. */
- private static final int MAX_RETRIES = 32;
+ /** Defensive ceiling on the number of slots one call may draw. */
+ private int maxSlotsPerAllocation = 1024;
- /** Max slots per list before rolling to the next {@code uriIndex}. */
- private int maxIndex = 500_000;
+ /** Key stabilising the counter-to-index permutation. */
+ private String permutationKey;
+
+ /** Permutation turning counter values into indices. */
+ @NonnullAfterInit
+ private FeistelIndexPermutation permutation;
/** Backing storage for the allocator state. */
private StorageService storage;
@@ -99,102 +125,145 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
}
/**
- * Get the maximum number of slots per list before rollover.
+ * Get the number of slots in a single bucket.
+ *
+ * @return bucket capacity
+ */
+ public long getCapacity() {
+ return CAPACITY;
+ }
+
+ /**
+ * Get the ceiling on the number of slots one call may draw.
*
- * @return max slots per list
+ * @return maximum slots per allocation
*/
- public int getMaxIndex() {
- return maxIndex;
+ public int getMaxSlotsPerAllocation() {
+ return maxSlotsPerAllocation;
}
/**
- * Set the maximum number of slots per list before rollover.
+ * Set the ceiling on the number of slots one call may draw.
+ *
+ * This is a defensive bound against a miscounted request, not a policy limit on
+ * batch size.
*
* @param max positive maximum
*/
- public void setMaxIndex(final int max) {
+ public void setMaxSlotsPerAllocation(final int max) {
checkSetterPreconditions();
- Constraint.isGreaterThan(999, max, "maxIndex must be at least 1000");
- maxIndex = max;
+ Constraint.isGreaterThan(0, max, "maxSlotsPerAllocation must be positive");
+ maxSlotsPerAllocation = max;
+ }
+
+ /**
+ * Set the key stabilising the counter-to-index permutation.
+ *
+ * A secret. Must not change after credentials have been issued, two keys can
+ * put two counters of the same bucket at the same index.
+ *
+ * @param key key material
+ */
+ public void setPermutationKey(@Nonnull @NotEmpty final String key) {
+ checkSetterPreconditions();
+ permutationKey = Constraint.isNotEmpty(key, "Permutation key cannot be empty");
}
/** {@inheritDoc} */
@Override
- public void doInitialize() throws ComponentInitializationException {
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
if (storage == null) {
throw new ComponentInitializationException("StorageService cannot be null");
}
+ if (permutationKey == null || permutationKey.isEmpty()) {
+ throw new ComponentInitializationException("Permutation key cannot be null or empty; set "
+ + "oauth.statuslist.indexPermutationKey to a stable secret value");
+ }
+ if (PLACEHOLDER_KEY.equals(permutationKey)) {
+ log.warn("Status list indices are permuted with the key shipped in the plugin's properties. "
+ + "Anyone can recover the issuance order of issued credentials until "
+ + "oauth.statuslist.indexPermutationKey is changed, and changing it later reissues slots.");
+ }
+ permutation = new FeistelIndexPermutation(INDEX_BITS, permutationKey.getBytes(StandardCharsets.UTF_8));
}
/**
- * Allocate the next slot.
+ * Allocate slots from a bucket.
*
- * 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.
+ * 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.
*
- * @return newly allocated slot
- * @throws IOException if the storage backend errors, or if contention could not
- * be resolved within {@value #MAX_RETRIES} attempts
+ * @param bucket bucket to draw from
+ * @param count number of slots needed
+ * @return newly allocated indices, in the order the credentials were requested
+ * @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
- public StatusListIndex allocate() throws IOException {
- for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
- final StorageRecord<String> current = storage.read(CONTEXT, KEY_COUNTER);
+ public List<Long> allocate(@Nonnull final StatusListBucket bucket, final int count) throws IOException {
+ checkComponentActive();
+ Constraint.isNotNull(bucket, "Bucket cannot be null");
+ Constraint.isGreaterThan(0, count, "Count must be positive");
+ Constraint.isTrue(count <= maxSlotsPerAllocation,
+ "Count " + count + " exceeds maximum " + maxSlotsPerAllocation + " slots per allocation");
- 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]);
- }
+ final String key = bucket.id();
+ final Long expiration = bucket.end().plus(RETENTION).toEpochMilli();
+
+ for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ final StorageRecord<String> current = storage.read(CONTEXT, key);
+ final long allocated = current == null ? 0L : Long.parseLong(current.getValue());
- int nextIndex = index + 1;
- long nextUriIndex = uriIndex;
- if (nextIndex >= maxIndex) {
- nextIndex = 0;
- nextUriIndex = uriIndex + 1L;
+ if (allocated + count > CAPACITY) {
+ throw new IOException("Status list bucket " + key + " exhausted: " + allocated + " of " + CAPACITY
+ + " slots allocated, " + count + " more requested.");
}
- final String nextValue = nextUriIndex + DEL + nextIndex;
+ final String nextValue = Long.toString(allocated + count);
final boolean committed;
if (current == null) {
- committed = storage.create(CONTEXT, KEY_COUNTER, nextValue, null);
+ committed = storage.create(CONTEXT, key, nextValue, expiration);
} else {
- committed = tryUpdate(current.getVersion(), nextValue);
+ committed = tryUpdate(current.getVersion(), key, nextValue, expiration);
}
if (committed) {
- log.debug("Allocated status list slot uriIndex={} index={}", nextUriIndex, nextIndex);
- return new StatusListIndex(nextIndex, nextUriIndex);
+ final List<Long> indices = new ArrayList<>(count);
+ for (long counter = allocated; counter < allocated + count; counter++) {
+ indices.add(permutation.permute(counter, key));
+ }
+ log.debug("Allocated {} status list slot(s) from bucket {}: {}", count, key, indices);
+ return indices;
}
- log.trace("Version mismatch on status list counter, attempt {}/{}", attempt + 1, MAX_RETRIES);
+ log.trace("Version mismatch on status list counter for bucket {}, attempt {}/{}", key, attempt + 1,
+ MAX_RETRIES);
}
- throw new IOException(
- "Failed to allocate status list slot after " + MAX_RETRIES + " attempts (storage contention).");
+ throw new IOException("Failed to allocate status list slots from bucket " + key + " after " + MAX_RETRIES
+ + " attempts (storage contention).");
}
/**
- * Update of the counter, returning {@code false} on version
- * mismatch.
+ * Update of a bucket's counter, returning {@code false} on version mismatch.
*
- * @param version expected version
- * @param value new packed value
+ * @param version expected version
+ * @param key bucket identifier
+ * @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
* @throws IOException on any other storage error
*/
- private boolean tryUpdate(final long version, @Nonnull final String value) throws IOException {
+ private boolean tryUpdate(final long version, @Nonnull final String key, @Nonnull final String value,
+ @Nonnull final Long expiration) throws IOException {
try {
- // null expiration = never expires; see class javadoc.
- storage.updateWithVersion(version, CONTEXT, KEY_COUNTER, value, null);
+ storage.updateWithVersion(version, CONTEXT, key, value, expiration);
return true;
} catch (final VersionMismatchException e) {
return false;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java
new file mode 100644
index 0000000..9280c07
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java
@@ -0,0 +1,242 @@
+/*
+ * 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 javax.annotation.Nullable;
+
+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.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+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;
+
+/**
+ * Store of the status lists this issuer publishes, backed by Shibboleth
+ * {@link StorageService}.
+ *
+ * A list is stored per {@link StatusListBucket} in
+ * {@value org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters#CHUNKS}
+ * pieces of fixed size, keyed by the bucket identifier and the number of the
+ * piece. Records expire when no credential of the bucket can be valid anymore.
+ */
+ at ThreadSafeAfterInit
+public class StatusListStore extends AbstractIdentifiableInitializableComponent {
+
+ /** Logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(StatusListStore.class);
+
+ /** Storage context for the lists. */
+ @Nonnull
+ @NotEmpty
+ private static final String CONTEXT = StatusListStore.class.getName();
+
+ /** Bounded retry cap for optimistic-update contention. */
+ private static final int MAX_RETRIES = 32;
+
+ /** Backing storage for the lists. */
+ private StorageService storage;
+
+ /**
+ * Get the backing store.
+ *
+ * @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());
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ if (storage == null) {
+ throw new ComponentInitializationException("StorageService cannot be null");
+ }
+
+ // Pieces are of fixed size, so a store wide enough for one is wide enough for
+ // every list this
+ // issuer will ever publish, whatever gets revoked.
+ final long required = StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length();
+ final long valueSize = storage.getCapabilities().getValueSize();
+ if (valueSize < required) {
+ throw new ComponentInitializationException(
+ "StorageService holds values of " + valueSize + " characters, status list pieces need " + required);
+ }
+ log.debug("Status lists of {} slots are stored in {} pieces of {} characters", StatusListParameters.CAPACITY,
+ StatusListParameters.CHUNKS, required);
+ }
+
+ /**
+ * Read the list of a bucket in the form a published token carries it.
+ *
+ * Combines the pieces of the list. A piece that was never written is skipped.
+ *
+ * @param bucket bucket to read
+ * @return the encoded list, or null if nothing is revoked in it
+ * @throws IOException if a stored piece cannot be read or the storage backend
+ * fails
+ */
+ @Nullable
+ public String read(@Nonnull final StatusListBucket bucket) throws IOException {
+ checkComponentActive();
+ Constraint.isNotNull(bucket, "Bucket cannot be null");
+
+ final byte[] list = StatusListCodec.empty();
+ int pieces = 0;
+
+ for (int chunk = 0; chunk < StatusListParameters.CHUNKS; chunk++) {
+ final StorageRecord<String> record = storage.read(CONTEXT, key(bucket, chunk));
+ if (record == null) {
+ continue;
+ }
+ System.arraycopy(StatusListCodec.decodeChunk(record.getValue()), 0, list,
+ chunk * StatusListParameters.CHUNK_BYTE_LENGTH, StatusListParameters.CHUNK_BYTE_LENGTH);
+ pieces++;
+ }
+
+ if (pieces == 0) {
+ return null;
+ }
+
+ log.debug("Assembled status list {} from {} stored piece(s)", bucket.id(), pieces);
+
+ return StatusListCodec.encode(list);
+ }
+
+ /**
+ * Get the storage key of one piece of a bucket's list.
+ *
+ * @param bucket bucket owning the list
+ * @param chunk piece of the list
+ * @return storage key
+ */
+ @Nonnull
+ private static String key(@Nonnull final StatusListBucket bucket, final int chunk) {
+ return bucket.id() + "#" + chunk;
+ }
+
+ /**
+ * 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
+ * created if it does not exist. Revoking an already revoked slot does nothing.
+ *
+ * @param bucket bucket of the list
+ * @param index slot to revoke
+ * @throws IOException if the stored piece cannot be read, the storage backend
+ * fails or contention was not resolved in
+ * {@value #MAX_RETRIES} attempts
+ */
+ public void revoke(@Nonnull final StatusListBucket bucket, final long index) throws IOException {
+ checkComponentActive();
+ Constraint.isNotNull(bucket, "Bucket cannot be null");
+ Constraint.isTrue(index >= 0 && index < StatusListParameters.CAPACITY,
+ "Slot " + index + " outside a list of " + StatusListParameters.CAPACITY);
+
+ final int chunk = StatusListParameters.chunkOf(index);
+ final long offset = StatusListParameters.offsetInChunk(index);
+ final String key = key(bucket, chunk);
+ final Long expiration = bucket.end().plus(StatusListParameters.RETENTION).toEpochMilli();
+
+ for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ final StorageRecord<String> current = storage.read(CONTEXT, key);
+
+ final byte[] piece = current == null ? StatusListCodec.emptyChunk()
+ : StatusListCodec.decodeChunk(current.getValue());
+
+ if (!StatusListCodec.isValid(piece, offset)) {
+ log.debug("Slot {} of status list {} is already revoked", index, bucket.id());
+ return;
+ }
+
+ StatusListCodec.setStatus(piece, offset, false);
+ final String updated = StatusListCodec.encodeChunk(piece);
+
+ final boolean committed;
+ if (current == null) {
+ committed = storage.create(CONTEXT, key, updated, expiration);
+ } else {
+ committed = tryUpdate(current.getVersion(), key, updated, expiration);
+ }
+
+ if (committed) {
+ log.info("Revoked slot {} of status list {}", index, bucket.id());
+ return;
+ }
+
+ log.trace("Version mismatch on status list piece {}, attempt {}/{}", key, attempt + 1, MAX_RETRIES);
+ }
+
+ throw new IOException("Failed to revoke slot " + index + " of status list " + bucket.id() + " after "
+ + MAX_RETRIES + " attempts (storage contention).");
+ }
+
+ /**
+ * Update of a list, returning {@code false} on version mismatch.
+ *
+ * @param version expected version
+ * @param key bucket identifier
+ * @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
+ * @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;
+ } catch (final VersionMismatchException e) {
+ return false;
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 2e5edca..b1abd1a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -16,6 +16,7 @@
package org.geant.shibboleth.plugin.openidvci.messaging.context;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -49,6 +50,14 @@ public class CredentialsContext extends BaseContext {
@Nullable
private List<SignedJWT> proofs;
+ /** Issuance instant shared by all credentials of this request. */
+ @Nullable
+ private Instant issuedAt;
+
+ /** Expiration instant shared by all credentials of this request. */
+ @Nullable
+ private Instant expiresAt;
+
/** Credential shell(s) per proof. */
@Nullable
private List<ClaimsSet> credentialShells;
@@ -84,6 +93,46 @@ public class CredentialsContext extends BaseContext {
this.proofs = proofs;
}
+ /**
+ * Get issuance instant shared by all credentials of this request.
+ *
+ * @return Issuance instant shared by all credentials of this request
+ */
+ @Nullable
+ public Instant getIssuedAt() {
+ return issuedAt;
+ }
+
+ /**
+ * Set issuance instant shared by all credentials of this request.
+ *
+ * @param issuanceInstant Issuance instant shared by all credentials of this
+ * request
+ */
+ public void setIssuedAt(@Nullable Instant issuanceInstant) {
+ this.issuedAt = issuanceInstant;
+ }
+
+ /**
+ * Get expiration instant shared by all credentials of this request.
+ *
+ * @return Expiration instant shared by all credentials of this request
+ */
+ @Nullable
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ /**
+ * Set expiration instant shared by all credentials of this request.
+ *
+ * @param expirationInstant Expiration instant shared by all credentials of this
+ * request
+ */
+ public void setExpiresAt(@Nullable Instant expirationInstant) {
+ this.expiresAt = expirationInstant;
+ }
+
/**
* Get credential content to for the final credential.
*
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 8b6f0d9..7f8a5cd 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
@@ -16,8 +16,7 @@
package org.geant.shibboleth.plugin.openidvci.profile.impl;
import java.text.ParseException;
-import java.time.Duration;
-import java.time.ZonedDateTime;
+import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@@ -31,12 +30,12 @@ import net.shibboleth.idp.profile.AbstractProfileAction;
import net.shibboleth.idp.profile.IdPEventIds;
import net.shibboleth.profile.config.ProfileConfiguration;
import net.shibboleth.profile.context.RelyingPartyContext;
-import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
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;
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
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;
@@ -86,8 +85,13 @@ public class AddCredentialShell extends AbstractProfileAction {
@Nullable
private RelyingPartyContext rpCtx;
- /** Lifetime of credential. */
- private Duration expiration;
+ /** Issuance instant shared by all credentials of this request. */
+ @Nullable
+ private Instant issuedAt;
+
+ /** Expiration instant shared by all credentials of this request. */
+ @Nullable
+ private Instant expiresAt;
/**
* Credential offer context.
@@ -102,7 +106,10 @@ public class AddCredentialShell extends AbstractProfileAction {
/** Constructor. */
public AddCredentialShell() {
relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
- issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
+ // The issuer is the did:jwk of the key that will sign, so that a credential
+ // cannot name an
+ // issuer whose key is not the one signing it.
+ issuerLookupStrategy = new CredentialIssuerLookupFunction();
}
/**
@@ -155,9 +162,7 @@ public class AddCredentialShell extends AbstractProfileAction {
return false;
}
final ProfileConfiguration pc = rpCtx.getProfileConfig();
- if (pc instanceof OpenIDVCIConfiguration) {
- expiration = ((OpenIDVCIConfiguration) pc).getCredentialLifetime(profileRequestContext);
- } else {
+ if (!(pc instanceof OpenIDVCIConfiguration)) {
log.error("{} No OpenID VCI profile configuration associated with this profile request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
return false;
@@ -170,12 +175,15 @@ public class AddCredentialShell extends AbstractProfileAction {
return false;
}
- // TODO: Move issuer from CredentialDefinition if used here!
- issuerId = ctx.getCredentialConfiguration().getCredentialDefinition() != null
- ? ctx.getCredentialConfiguration().getCredentialDefinition().getIssuer()
- : null;
- issuerId = issuerId == null ? issuerLookupStrategy.apply(profileRequestContext) : issuerId;
+ issuedAt = ctx.getIssuedAt();
+ expiresAt = ctx.getExpiresAt();
+ if (issuedAt == null || expiresAt == null) {
+ log.error("{} Credential validity window has not been resolved for this profile request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
+ issuerId = issuerLookupStrategy.apply(profileRequestContext);
if (issuerId == null) {
log.error("{} Unable to determine issuer to check audience", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
@@ -196,10 +204,11 @@ public class AddCredentialShell extends AbstractProfileAction {
List<ClaimsSet> shells = new ArrayList<>();
if (ctx.getProofs() != null && !ctx.getProofs().isEmpty()) {
- for (SignedJWT proof : ctx.getProofs()) {
- ClaimsSet shell = createShell();
+ final List<SignedJWT> proofs = ctx.getProofs();
+ for (int ordinal = 0; ordinal < proofs.size(); ordinal++) {
+ ClaimsSet shell = createShell(ordinal);
try {
- Map<String, Object> cnf = buildCnf(proof);
+ Map<String, Object> cnf = buildCnf(proofs.get(ordinal));
if (cnf != null) {
shell.setClaim("cnf", cnf);
}
@@ -211,15 +220,15 @@ public class AddCredentialShell extends AbstractProfileAction {
shells.add(shell);
}
} else {
- shells.add(createShell());
+ shells.add(createShell(0));
}
ctx.setCredentialShells(shells);
}
/**
- * Build cnf claim for response. Content is derived either from proof jwk header
- * or kid value.
- *
+ * Build cnf claim for response. A did:jwk key reference from the proof kid
+ * header is preferred, an embedded proof jwk header is the fallback.
+ *
* @param proof previously verified proof
* @return cnf claim
* @throws JsonProcessingException if something unexpected occurs.
@@ -227,41 +236,45 @@ public class AddCredentialShell extends AbstractProfileAction {
*/
private Map<String, Object> buildCnf(SignedJWT proof) throws JsonProcessingException, ParseException {
+ final String kid = proof.getHeader().getKeyID();
+ if (kid != null && kid.startsWith("did:jwk:") && kid.endsWith("#0")) {
+ return Map.of("kid", kid);
+ }
if (proof.getHeader().getJWK() != null) {
return Map.of("jwk", proof.getHeader().getJWK().toJSONObject());
}
- if (proof.getHeader().getKeyID() != null && proof.getHeader().getKeyID().startsWith("did:jwk:")
- && proof.getHeader().getKeyID().endsWith("#0")) {
- return Map.of("kid", proof.getHeader().getKeyID());
- }
return null;
}
/**
* Create the shell for credential.
- *
+ *
+ * @param ordinal position of the credential within this issuance, selecting the
+ * status list slot allocated for it
* @return credential shell.
*/
- private ClaimsSet createShell() {
+ private ClaimsSet createShell(final int ordinal) {
ClaimsSet shell = new ClaimsSet();
shell.setIssuer(new Issuer(issuerId));
- ZonedDateTime now = ZonedDateTime.now();
- shell.setClaim("iat", now.toEpochSecond());
- shell.setClaim("exp", now.toEpochSecond() + expiration.toSeconds());
+ shell.setClaim("iat", issuedAt.getEpochSecond());
+ shell.setClaim("exp", expiresAt.getEpochSecond());
if ("dc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
shell.setClaim("vct", ctx.getCredentialIdentifier());
}
if ("vc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
- shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(now));
- shell.setClaim("validUntil",
- DateTimeFormatter.ISO_INSTANT.format(now.plusSeconds(expiration.getSeconds())));
+ shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(issuedAt));
+ shell.setClaim("validUntil", DateTimeFormatter.ISO_INSTANT.format(expiresAt));
}
- 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())));
+ if (statusListContext != null && statusListContext.getStatusListUri() != null) {
+ final Long index = statusListContext.getIndex(ordinal);
+ if (index != null) {
+ shell.setClaim("status",
+ Map.of("status_list", Map.of("idx", index, "uri", statusListContext.getStatusListUri())));
+ } else {
+ log.warn("{} No status list slot allocated for credential {} of this issuance", getLogPrefix(),
+ ordinal);
+ }
}
return shell;
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
index b49625c..030c640 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
@@ -25,6 +25,7 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
@@ -37,7 +38,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
import net.shibboleth.shared.logic.Constraint;
@@ -80,7 +80,7 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
* Constructor.
*/
public FormJsonLdSelectiveDisclosureJWTCredential() {
- issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
+ issuerLookupStrategy = new CredentialIssuerLookupFunction();
}
/**
@@ -150,10 +150,9 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
: disclosures + "~" + sdActClaims.getFormattedDisclosures();
}
List<JWTClaimsSet> credentials = new ArrayList<>();
+ final String issuer = issuerLookupStrategy.apply(profileRequestContext);
ctx.getCredentialShells().forEach(cred -> {
try {
- String issuer = ctx.getCredentialConfiguration().getCredentialDefinition().getIssuer();
- issuer = issuer == null ? issuerLookupStrategy.apply(profileRequestContext) : issuer;
credentials.add(new JWTClaimsSet.Builder(cred.toJWTClaimsSet())
.claim("@context", ctx.getCredentialConfiguration().getCredentialDefinition().getContext())
.claim("id", "urn:uuid:" + UUID.randomUUID())
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
index 4eba047..d86ac67 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
@@ -131,6 +131,8 @@ public class ParseProof extends AbstractProfileAction {
return;
}
List<SignedJWT> proofs = new ArrayList<SignedJWT>();
+ // TODO: Enforce a maximum number of proofs per request, aligned with the
+ // batch_credential_issuance.batch_size value advertised in issuer metadata.
if (proofToken instanceof List<?> tokens) {
tokens.forEach(token -> {
if (token instanceof String strToken) {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java
new file mode 100644
index 0000000..0ff21bb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java
@@ -0,0 +1,145 @@
+/*
+ * 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.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.profile.config.ProfileConfiguration;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.openidvci.config.OpenIDVCIConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action that resolves the validity window shared by every credential issued from
+ * this request and sets it to work context {@link CredentialsContext} located
+ * under {@link ProfileRequestContext#getInboundMessageContext()}.
+ *
+ * Resolved once, so that all credentials of a batch have the same 'iat' and 'exp',
+ * and so that the status list chosen by the expiration matches the expiration that
+ * is issued. Credentials of one batch with different expirations could be
+ * recognized as siblings. An expiration resolved twice could also outlive the
+ * status list chosen for it.
+ */
+public class ResolveCredentialLifetime extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(ResolveCredentialLifetime.class);
+
+ /**
+ * Strategy used to locate the {@link RelyingPartyContext} associated with a
+ * given {@link ProfileRequestContext}.
+ */
+ @Nonnull
+ private Function<ProfileRequestContext, RelyingPartyContext> relyingPartyContextLookupStrategy;
+
+ /** Lifetime of credential. */
+ @Nullable
+ private Duration lifetime;
+
+ /** Credentials context. */
+ @Nullable
+ private CredentialsContext ctx;
+
+ /** Constructor. */
+ public ResolveCredentialLifetime() {
+ relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
+ }
+
+ /**
+ * Set the strategy used to locate the {@link RelyingPartyContext} associated
+ * with a given {@link ProfileRequestContext}.
+ *
+ * @param strategy strategy used to locate the {@link RelyingPartyContext}
+ * associated with a given {@link ProfileRequestContext}
+ */
+ public void setRelyingPartyContextLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, RelyingPartyContext> strategy) {
+ ifInitializedThrowUnmodifiabledComponentException();
+
+ relyingPartyContextLookupStrategy = Constraint.isNotNull(strategy,
+ "RelyingPartyContext lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+
+ final RelyingPartyContext rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
+ if (rpCtx == null) {
+ log.debug("{} No relying party context associated with this profile request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+ return false;
+ }
+
+ final ProfileConfiguration pc = rpCtx.getProfileConfig();
+ if (!(pc instanceof OpenIDVCIConfiguration)) {
+ log.error("{} No OpenID VCI profile configuration associated with this profile request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+ return false;
+ }
+ lifetime = ((OpenIDVCIConfiguration) pc).getCredentialLifetime(profileRequestContext);
+ if (lifetime == null) {
+ log.error("{} No credential lifetime available", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
+
+ if (profileRequestContext.getInboundMessageContext() == null) {
+ log.error("{} No inbound message context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ ctx = profileRequestContext.getInboundMessageContext().ensureSubcontext(CredentialsContext.class);
+
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ assert ctx != null && lifetime != null;
+
+ final Instant issuedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS);
+ ctx.setIssuedAt(issuedAt);
+ ctx.setExpiresAt(issuedAt.plus(lifetime));
+
+ log.debug("{} Credentials of this request are valid from {} until {}", getLogPrefix(), ctx.getIssuedAt(),
+ ctx.getExpiresAt());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
index dbb30e7..8e388d0 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
@@ -215,7 +215,7 @@ public class UnwrapGrant extends AbstractOpenIDVCITokenResponseAction {
.ensureSubcontext(TokenContext.class);
tokenContext.setCredentialOfferObject(credentialOfferCache.getCredentialOffer(code));
setPotentialCredentials(tokenContext);
- credentialOfferCache.removeCredentialOffer(code);
+ //credentialOfferCache.removeCredentialOffer(code);
// Subject and auth time set for access token creation. TODO make sure what
// content to use.
getOidcResponseContext().setSubject(clientIDLookupStrategy.apply(profileRequestContext).getValue());
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java
new file mode 100644
index 0000000..be790ab
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java
@@ -0,0 +1,58 @@
+/*
+ * 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.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import com.nimbusds.jwt.SignedJWT;
+
+/**
+ * Supplies the number of credentials a request will produce, being one per
+ * validated proof in {@link CredentialsContext}.
+ *
+ * A request without proofs gives one credential without key binding, so the
+ * count is one.
+ */
+public class CredentialCountLookupFunction implements Function<ProfileRequestContext, Integer> {
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Integer apply(@Nullable final ProfileRequestContext profileRequestContext) {
+ if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+ return 1;
+ }
+
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ if (ctx == null) {
+ return 1;
+ }
+
+ final List<SignedJWT> proofs = ctx.getProofs();
+
+ return proofs == null || proofs.isEmpty() ? 1 : proofs.size();
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
new file mode 100644
index 0000000..d069dac
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
@@ -0,0 +1,92 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+/**
+ * Describes an issued credential for the record of the status list slots it took.
+ *
+ * This is all that OpenID VCI does for recording an assignment. The status list side
+ * knows nothing about credentials.
+ *
+ * Claims are reported by path with their values, a claim of a nested object as
+ * 'address.locality'. Values are taken from what was requested to be issued. The
+ * credential itself has only digests of them.
+ */
+public class CredentialDetailsLookupFunction implements Function<ProfileRequestContext, Map<String, Object>> {
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public Map<String, Object> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+ final Map<String, Object> details = new HashMap<>();
+
+ if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+ return details;
+ }
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ if (ctx == null) {
+ return details;
+ }
+
+ details.put("credential_configuration_id", ctx.getCredentialIdentifier());
+ if (ctx.getCredentialConfiguration() != null) {
+ details.put("format", ctx.getCredentialConfiguration().getFormat());
+ }
+ details.put("claims", claims(ctx));
+
+ return details;
+ }
+
+ /**
+ * Collect the claims of the credential by path.
+ *
+ * @param ctx credentials context to read
+ * @return claims by path
+ */
+ @Nonnull
+ private Map<String, Object> claims(@Nonnull final CredentialsContext ctx) {
+ final Map<String, Object> claims = new HashMap<>();
+
+ if (ctx.getCredentialContent() == null || ctx.getCredentialContent().getRequestedCredential() == null) {
+ return claims;
+ }
+
+ for (final CredentialOfferRequestedClaim claim : ctx.getCredentialContent().getRequestedCredential()) {
+ final List<String> path = claim.getPath();
+ if (path == null || path.isEmpty()) {
+ continue;
+ }
+ claims.put(String.join(".", path), claim.getValue());
+ }
+
+ return claims;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java
new file mode 100644
index 0000000..8205afc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java
@@ -0,0 +1,50 @@
+/*
+ * 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.time.Instant;
+import java.util.function.Function;
+
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+/**
+ * Supplies the expiration shared by the credentials of a request, as resolved
+ * into {@link CredentialsContext}.
+ *
+ * Returns null if no expiration has been resolved. The caller decides if that
+ * is an error.
+ */
+public class CredentialExpirationLookupFunction implements Function<ProfileRequestContext, Instant> {
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public Instant apply(@Nullable final ProfileRequestContext profileRequestContext) {
+ if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+ return null;
+ }
+
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+
+ return ctx == null ? null : ctx.getExpiresAt();
+ }
+
+}
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
index edab3aa..3c55919 100644
--- 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
@@ -14,12 +14,40 @@
<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()}" />
+ p:id="oauth.StatusListIndexAllocator"
+ p:storage-ref="#{'%{oauth.StatusListIndexAllocator:shibboleth.StorageService}'.trim()}"
+ p:permutationKey="%{oauth.statuslist.indexPermutationKey:}" />
<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" />
+ p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier">
+ <property name="expirationLookupStrategy">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
+ </property>
+ <property name="indexCountLookupStrategy">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialCountLookupFunction" />
+ </property>
+ </bean>
+
+ <bean id="oauth.StatusListAssignmentStore"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore"
+ depends-on="shibboleth.LoggingService"
+ p:id="oauth.StatusListAssignmentStore"
+ p:retention="%{oauth.statuslist.assignmentRetention:P7D}"
+ p:storage-ref="#{'%{oauth.StatusListAssignmentStore:shibboleth.StorageService}'.trim()}" />
+
+ <bean id="AssignIndex"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.AssignIndex"
+ scope="prototype"
+ p:statusListAssignmentStore-ref="oauth.StatusListAssignmentStore">
+ <property name="expirationLookupStrategy">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
+ </property>
+ <property name="detailsLookupStrategy">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialDetailsLookupFunction" />
+ </property>
+ </bean>
</beans>
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
new file mode 100644
index 0000000..520dfa2
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
@@ -0,0 +1,61 @@
+<?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="shibboleth.openidvci.profileId" class="java.lang.String"
+ c:_0="http://geant.org/ns/profiles/oauth/status-list/assignments"/>
+
+ <bean id="DecodeMessage" class="org.opensaml.profile.action.impl.DecodeMessage" scope="prototype">
+ <constructor-arg>
+ <bean class="org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl.StatusListAssignmentsRequestDecoder"
+ scope="prototype" p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+ </constructor-arg>
+ </bean>
+
+ <bean id="InitializeAuthenticationContext"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.InitializeAuthenticationContext"
+ scope="prototype" />
+
+ <bean id="shibboleth.ClientIDLookupStrategy"
+ class="net.shibboleth.idp.plugin.oidc.op.messaging.context.navigate.TokenRequestClientIDLookupFunction" />
+
+ <bean id="oauth.StatusListAssignmentStore"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore"
+ depends-on="shibboleth.LoggingService"
+ p:id="oauth.StatusListAssignmentStore"
+ p:retention="%{oauth.statuslist.assignmentRetention:P7D}"
+ p:storage-ref="#{'%{oauth.StatusListAssignmentStore:shibboleth.StorageService}'.trim()}" />
+
+ <bean id="FormOutboundMessage"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListAssignmentsResponseMessage"
+ scope="prototype"
+ p:statusListAssignmentStore-ref="oauth.StatusListAssignmentStore" />
+
+ <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+ scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="eventContextLookupStrategy">
+ <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+ </property>
+ <property name="mappedErrors">
+ <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+ <entry key="#{T(org.opensaml.profile.action.EventIds).INVALID_MSG_CTX}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_REQUEST}" />
+ <entry key="#{T(org.opensaml.profile.action.EventIds).ACCESS_DENIED}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_CLIENT}" />
+ <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+ </map>
+ </property>
+ </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-flow.xml
new file mode 100644
index 0000000..616c013
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-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"
+ parent="openid/vci/abstract-api">
+
+ <action-state id="InitializeMandatoryContexts">
+ <evaluate expression="InitializeProfileRequestContext" />
+ <evaluate expression="PopulateMetricContext" />
+ <evaluate expression="FlowStartPopulateAuditContext" />
+ <evaluate expression="InitializeOutboundMessageContext" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="DecodeMessage" />
+ </action-state>
+
+ <action-state id="ResumeAfterAuthentication">
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="BuildResponseMessage" />
+ </action-state>
+
+ <bean-import
+ resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
new file mode 100644
index 0000000..7e54537
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
@@ -0,0 +1,86 @@
+<?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="shibboleth.openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/status-list"/>
+
+ <bean id="InitializeUnverifiedRelyingPartyContext"
+ class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+ <bean id="PopulateStatusListSignatureSigningParameters"
+ class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureSigningParameters"
+ scope="prototype" p:noResultIsError="true"
+ c:strategy-ref="shibboleth.MessageContextLookup.Outbound">
+ <property name="configurationLookupStrategy">
+ <bean lazy-init="true"
+ class="net.shibboleth.oidc.profile.config.navigate.JWTSignatureSigningConfigurationLookupFunction" />
+ </property>
+ <!-- CHANGE TO Basic and configure sec conf having correct type of key only-->
+ <!-- Security configuration in general needs lot of work still -->
+ <property name="signatureSigningParametersResolver">
+ <bean class="net.shibboleth.oidc.security.jose.impl.ClientInformationSignatureSigningParametersResolver">
+ <constructor-arg name="signatureAlgorithmLookupStrategy">
+ <bean
+ class="net.shibboleth.oidc.profile.config.navigate.ClientInformationStringValueLookupFunction"
+ c:keyName="id_token_signed_response_alg" />
+ </constructor-arg>
+ <constructor-arg name="defaultAlgorithmValue" value="ES256" />
+ </bean>
+ </property>
+ </bean>
+
+ <bean id="openidvci.IssuerLookupStrategy"
+ class="org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction"
+ p:issuer="%{openidvci.issuer:}" />
+
+ <bean id="oauth.StatusListStore"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore"
+ depends-on="shibboleth.LoggingService"
+ p:id="oauth.StatusListStore"
+ p:storage-ref="#{'%{oauth.StatusListStore:shibboleth.StorageService}'.trim()}" />
+
+ <bean id="FormStatusListToken"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormStatusListToken"
+ scope="prototype"
+ p:statusListStore-ref="oauth.StatusListStore"
+ p:ttl="%{oauth.statuslist.ttl:PT1H}"
+ p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy"
+ p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+
+ <bean id="SignStatusListToken"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.SignStatusListToken"
+ scope="prototype" />
+
+ <bean id="FormOutboundMessage"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListResponseMessage"
+ scope="prototype" />
+ <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+ scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="eventContextLookupStrategy">
+ <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+ </property>
+ <property name="mappedErrors">
+ <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+ <!-- Unknown list is 404 and a retired list is 410, so a verifier can tell them apart. -->
+ <entry key="#{T(org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds).NO_STATUS_LIST}"
+ value="#{T(org.geant.shibboleth.plugin.oauth.statuslist.messaging.error.StatusListError).UNKNOWN_STATUS_LIST}" />
+ <entry key="#{T(org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds).STATUS_LIST_RETIRED}"
+ value="#{T(org.geant.shibboleth.plugin.oauth.statuslist.messaging.error.StatusListError).RETIRED_STATUS_LIST}" />
+ <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+ </map>
+ </property>
+ </bean>
+
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
similarity index 76%
copy from openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
copy to openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
index bfe9ebd..7b2d3f8 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
@@ -12,10 +12,13 @@
<evaluate expression="InitializeUnverifiedRelyingPartyContext" />
<evaluate expression="SelectRelyingPartyConfiguration" />
<evaluate expression="SelectProfileConfiguration" />
+ <evaluate expression="PopulateStatusListSignatureSigningParameters" />
+ <evaluate expression="FormStatusListToken" />
+ <evaluate expression="SignStatusListToken" />
<evaluate expression="'proceed'"/>
<transition on="proceed" to="BuildResponseMessage"/>
</action-state>
- <bean-import resource="nonce-beans.xml"/>
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml
new file mode 100644
index 0000000..10deaed
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml
@@ -0,0 +1,65 @@
+<?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="shibboleth.openidvci.profileId" class="java.lang.String"
+ c:_0="http://geant.org/ns/profiles/oauth/status-list/revoke"/>
+
+ <bean id="DecodeMessage" class="org.opensaml.profile.action.impl.DecodeMessage" scope="prototype">
+ <constructor-arg>
+ <bean class="org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl.StatusListRevokeRequestDecoder"
+ scope="prototype" p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+ </constructor-arg>
+ </bean>
+
+ <bean id="InitializeAuthenticationContext"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.InitializeAuthenticationContext"
+ scope="prototype" />
+
+ <bean id="shibboleth.ClientIDLookupStrategy"
+ class="net.shibboleth.idp.plugin.oidc.op.messaging.context.navigate.TokenRequestClientIDLookupFunction" />
+
+ <bean id="oauth.StatusListStore"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore"
+ depends-on="shibboleth.LoggingService"
+ p:id="oauth.StatusListStore"
+ p:storage-ref="#{'%{oauth.StatusListStore:shibboleth.StorageService}'.trim()}" />
+
+ <bean id="RevokeStatusListIndex"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.RevokeStatusListIndex"
+ scope="prototype"
+ p:statusListStore-ref="oauth.StatusListStore" />
+
+ <bean id="FormOutboundMessage"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListRevokeResponseMessage"
+ scope="prototype" />
+
+ <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+ scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="eventContextLookupStrategy">
+ <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+ </property>
+ <property name="mappedErrors">
+ <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+ <entry key="#{T(org.opensaml.profile.action.EventIds).INVALID_MSG_CTX}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_REQUEST}" />
+ <entry key="#{T(org.opensaml.profile.action.EventIds).ACCESS_DENIED}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_CLIENT}" />
+ <!-- Storage refused the write, so the slot is not revoked and the caller has to retry. -->
+ <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+ </map>
+ </property>
+ </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-flow.xml
new file mode 100644
index 0000000..897e765
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-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"
+ parent="openid/vci/abstract-api">
+
+ <action-state id="InitializeMandatoryContexts">
+ <evaluate expression="InitializeProfileRequestContext" />
+ <evaluate expression="PopulateMetricContext" />
+ <evaluate expression="FlowStartPopulateAuditContext" />
+ <evaluate expression="InitializeOutboundMessageContext" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="DecodeMessage" />
+ </action-state>
+
+ <action-state id="ResumeAfterAuthentication">
+ <evaluate expression="RevokeStatusListIndex" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="BuildResponseMessage" />
+ </action-state>
+
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
index 67a33cb..5255c2b 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
@@ -62,5 +62,5 @@
</on-entry>
</end-state>
- <bean-import resource="../abstract-api/abstract-api-beans.xml"/>
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
index 3261b86..6f37da9 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
@@ -16,6 +16,7 @@
<transition on-exception="java.lang.RuntimeException" to="LogRuntimeException"/>
<transition on="#{!'proceed'.equals(currentEvent.id)}" to="HandleError"/>
</global-transitions>
- <bean-import resource="../abstract/abstract-beans.xml"/>
+
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-beans.xml"/>
</flow>
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 25b765e..c456a4d 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
@@ -22,6 +22,6 @@
<transition on="proceed" to="BuildResponseMessage"/>
</action-state>
- <bean-import resource="credential-offer-beans.xml"/>
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index 69667ba..254cc31 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -134,14 +134,21 @@
</property>
</bean>
- <bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
+ <bean id="ResolveCredentialLifetime" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ResolveCredentialLifetime"
scope="prototype" />
-
+
+ <bean id="openidvci.IssuerLookupStrategy"
+ class="org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction"
+ p:issuer="%{openidvci.issuer:}" />
+
+ <bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
+ scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
+
<bean id="OptionallyFormSelectiveDisclosureJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormSelectiveDisclosureJWTCredential"
scope="prototype" />
-
+
<bean id="OptionallyFormJsonLdSelectiveDisclosureJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormJsonLdSelectiveDisclosureJWTCredential"
- scope="prototype" />
+ scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
<bean id="SignJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SignJWTCredential"
scope="prototype" p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" >
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 fad314f..46d71fd 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
@@ -32,11 +32,18 @@
<evaluate expression="PopulateProofSignatureValidationParameters" />
<evaluate expression="ValidateProofSignature" />
<evaluate expression="PopulateCredentialsSignatureSigningParameters" />
+ <!-- Fixes iat/exp for all credentials of this request. The status list bucket comes from it. -->
+ <evaluate expression="ResolveCredentialLifetime" />
<evaluate expression="'proceed'" />
<transition on="proceed" to="AllocateStatusListIndex" />
</action-state>
<action-state id="ResumeAfterAllocateStatusListIndex">
+ <evaluate expression="'proceed'" />
+ <transition on="proceed" to="AssignStatusListIndex" />
+ </action-state>
+
+ <action-state id="ResumeAfterAssignStatusListIndex">
<evaluate expression="'proceed'" />
<transition on="proceed" to="BuildResponse" />
</action-state>
@@ -50,6 +57,6 @@
<transition on="proceed" to="BuildResponseMessage" />
</action-state>
- <bean-import resource="credentials-beans.xml" />
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml" />
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
index bfe9ebd..3e1ab2a 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
@@ -16,6 +16,6 @@
<transition on="proceed" to="BuildResponseMessage"/>
</action-state>
- <bean-import resource="nonce-beans.xml"/>
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
index 6cc1f16..3eb8e61 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
@@ -74,6 +74,6 @@
</action-state>
- <bean-import resource="token-beans.xml"/>
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 053013b..50f4861 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -46,7 +46,17 @@
<bean id="OpenID.VCI.Credentials" parent="AbstractVCIProfile" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCICredentialsConfiguration" />
-
+
+ <bean id="OAuth.StatusList" lazy-init="true"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListConfiguration"
+ p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
+ <bean id="OAuth.StatusList.Revoke" parent="AbstractVCIProfile" lazy-init="true"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListRevokeConfiguration" />
+
+ <bean id="OAuth.StatusList.Assignments" parent="AbstractVCIProfile" lazy-init="true"
+ class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListAssignmentsConfiguration" />
+
<!-- The generator does not tie client id to nonce to accomodate unprotected Nonce endpoint. -->
<bean id="openidvci.DefaultOpenIDVCINonceGenerator" parent="DefaultOAuth2DPoPNonceGenerator" lazy-init="true"
p:relyingPartyIdLookupStrategy-ref="openidvci.RelyingPartyForNonce">
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index fc6bb0e..d52d02a 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -1 +1,4 @@
-#placeholder
\ No newline at end of file
+oauth.statuslist.indexPermutationKey=change_this_to_secret
+oauth.statuslist.ttl=PT1H
+oauth.statuslist.assignmentRetention=P7D
+openidvci.issuer=
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java
new file mode 100644
index 0000000..8bf475c
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.profile.logic;
+
+import java.security.interfaces.ECPublicKey;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+
+/**
+ * Tests for {@link CredentialIssuerLookupFunction}
+ */
+public class CredentialIssuerLookupFunctionTest {
+
+ private static final String X = "qQ9ESeIrQ36JijWM-8xdcjXwY46RW3p9YDtP0MVaLnE";
+
+ private static final String Y = "5yVZRqwp1tde_CnKC662wW-XFQhEOrGAPi0LTG4OpI8";
+
+ private static final String EXPECTED = "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6InFROUVTZUlyUTM2SmlqV00tO"
+ + "HhkY2pYd1k0NlJXM3A5WUR0UDBNVmFMbkUiLCJ5IjoiNXlWWlJxd3AxdGRlX0NuS0M2NjJ3Vy1YRlFoRU9yR0FQaTBMVEc0T3BJOCJ9";
+
+ private ECPublicKey publicKey() throws Exception {
+ return new ECKey.Builder(Curve.P_256, new com.nimbusds.jose.util.Base64URL(X),
+ new com.nimbusds.jose.util.Base64URL(Y)).build().toECPublicKey();
+ }
+
+ @Test
+ public void testDerivesIdentifierOfKeyInService() throws Exception {
+ Assert.assertEquals(new CredentialIssuerLookupFunction().toDidJwk(publicKey()), EXPECTED);
+ }
+
+ @Test
+ public void testEncodesOnlyKeyMembers() throws Exception {
+ final ECKey annotated = new ECKey.Builder(Curve.P_256, new com.nimbusds.jose.util.Base64URL(X),
+ new com.nimbusds.jose.util.Base64URL(Y)).keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE)
+ .algorithm(com.nimbusds.jose.JWSAlgorithm.ES256).keyID("some-kid").build();
+
+ Assert.assertEquals(new CredentialIssuerLookupFunction().toDidJwk(annotated.toECPublicKey()), EXPECTED);
+ }
+
+ @Test
+ public void testNullKey() {
+ Assert.assertNull(new CredentialIssuerLookupFunction().toDidJwk(null));
+ }
+
+ @Test
+ public void testNullProfileRequestContext() {
+ Assert.assertNull(new CredentialIssuerLookupFunction().apply(null));
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java
new file mode 100644
index 0000000..1fb44a0
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.Inflater;
+
+import com.nimbusds.jose.util.Base64URL;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link StatusListCodec}
+ */
+public class StatusListCodecTest {
+
+ @Test
+ public void testSizes() {
+ Assert.assertEquals(StatusListCodec.empty().length, StatusListParameters.BYTE_LENGTH);
+ Assert.assertEquals(StatusListCodec.emptyChunk().length, StatusListParameters.CHUNK_BYTE_LENGTH);
+ Assert.assertEquals(StatusListParameters.CHUNK_BYTE_LENGTH * StatusListParameters.CHUNKS,
+ StatusListParameters.BYTE_LENGTH);
+ }
+
+ @Test
+ public void testEmptyListIsAllValid() {
+ final byte[] list = StatusListCodec.empty();
+ for (long index = 0; index < 64; index++) {
+ Assert.assertTrue(StatusListCodec.isValid(list, index));
+ }
+ Assert.assertTrue(StatusListCodec.isValid(list, StatusListParameters.CAPACITY - 1));
+ }
+
+ @Test
+ public void testBitPositionOfSlot() {
+ byte[] list = StatusListCodec.empty();
+ StatusListCodec.setStatus(list, 0, false);
+ Assert.assertEquals(list[0], (byte) 0x01);
+
+ list = StatusListCodec.empty();
+ StatusListCodec.setStatus(list, 1, false);
+ Assert.assertEquals(list[0], (byte) 0x02);
+
+ list = StatusListCodec.empty();
+ StatusListCodec.setStatus(list, 7, false);
+ Assert.assertEquals(list[0], (byte) 0x80);
+
+ list = StatusListCodec.empty();
+ StatusListCodec.setStatus(list, 8, false);
+ Assert.assertEquals(list[0], (byte) 0x00);
+ Assert.assertEquals(list[1], (byte) 0x01);
+ }
+
+ @Test
+ public void testRevokeAndRestore() {
+ final byte[] list = StatusListCodec.empty();
+
+ StatusListCodec.setStatus(list, 14314, false);
+ Assert.assertFalse(StatusListCodec.isValid(list, 14314));
+ Assert.assertTrue(StatusListCodec.isValid(list, 14313));
+ Assert.assertTrue(StatusListCodec.isValid(list, 14315));
+
+ StatusListCodec.setStatus(list, 14314, true);
+ Assert.assertTrue(StatusListCodec.isValid(list, 14314));
+ }
+
+ @Test
+ public void testSlotAddressedWithinThePieceGiven() {
+ final byte[] chunk = StatusListCodec.emptyChunk();
+ final long last = StatusListParameters.CHUNK_CAPACITY - 1;
+
+ StatusListCodec.setStatus(chunk, last, false);
+ Assert.assertFalse(StatusListCodec.isValid(chunk, last));
+ Assert.assertEquals(chunk[StatusListParameters.CHUNK_BYTE_LENGTH - 1], (byte) 0x80);
+
+ try {
+ StatusListCodec.setStatus(chunk, StatusListParameters.CHUNK_CAPACITY, false);
+ Assert.fail("A slot beyond the piece should have been rejected");
+ } catch (IndexOutOfBoundsException e) {
+ }
+ }
+
+ @Test
+ public void testSlotOutsideList() {
+ final byte[] list = StatusListCodec.empty();
+ try {
+ StatusListCodec.setStatus(list, StatusListParameters.CAPACITY, false);
+ Assert.fail("A slot beyond the list should have been rejected");
+ } catch (IndexOutOfBoundsException e) {
+ }
+ try {
+ StatusListCodec.isValid(list, -1);
+ Assert.fail("A negative slot should have been rejected");
+ } catch (IndexOutOfBoundsException e) {
+ }
+ }
+
+ @Test
+ public void testEncodedListIsZlibDeflate() throws Exception {
+ final byte[] list = StatusListCodec.empty();
+ StatusListCodec.setStatus(list, 14314, false);
+
+ final byte[] compressed = new Base64URL(StatusListCodec.encode(list)).decode();
+
+ Assert.assertEquals(compressed[0] & 0x0f, 8);
+ Assert.assertEquals(((compressed[0] & 0xff) * 256 + (compressed[1] & 0xff)) % 31, 0);
+
+ final Inflater inflater = new Inflater();
+ try {
+ inflater.setInput(compressed);
+ final ByteArrayOutputStream inflated = new ByteArrayOutputStream();
+ final byte[] buffer = new byte[8192];
+ while (!inflater.finished()) {
+ inflated.write(buffer, 0, inflater.inflate(buffer));
+ }
+ Assert.assertEquals(inflated.toByteArray(), list);
+ } finally {
+ inflater.end();
+ }
+ }
+
+ @Test
+ public void testEmptyListCompressesSmall() {
+ Assert.assertTrue(StatusListCodec.encode(StatusListCodec.empty()).length() < 500);
+ }
+
+ @Test
+ public void testChunkRoundTrip() throws Exception {
+ final byte[] chunk = StatusListCodec.emptyChunk();
+ StatusListCodec.setStatus(chunk, 1234, false);
+
+ final String encoded = StatusListCodec.encodeChunk(chunk);
+ Assert.assertEquals(StatusListCodec.decodeChunk(encoded), chunk);
+
+ Assert.assertEquals(encoded.length(), StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length());
+ }
+
+ @Test
+ public void testDecodeRejectsWrongLength() {
+ try {
+ StatusListCodec.decodeChunk(StatusListCodec.encodeChunk(new byte[8]));
+ Assert.fail("A piece of the wrong length should have been rejected");
+ } catch (Exception e) {
+ }
+ }
+
+}
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
index 4d20437..5a6afd5 100644
--- 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
@@ -17,8 +17,11 @@
package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
import java.io.IOException;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
import org.opensaml.messaging.context.MessageContext;
@@ -44,7 +47,15 @@ import net.shibboleth.shared.primitive.NonnullSupplier;
*/
public class AllocateIndexTest {
- private static final int TEST_MAX_INDEX = 1000;
+ private static final String TEST_KEY = "test-permutation-key";
+
+ /** Expiration falling in the 2027-02 bucket. */
+ private static final Instant EXPIRATION = Instant.parse("2027-02-18T12:00:00Z");
+
+ /** Expiration falling in the 2027-03 bucket. */
+ private static final Instant LATER_EXPIRATION = Instant.parse("2027-03-01T00:00:00Z");
+
+ private static final String EXPECTED_URI = "https://issuer.example.org/idp/profile/oauth/status-list/list?id=2027-02";
protected RequestContext requestCtx;
@@ -65,8 +76,9 @@ public class AllocateIndexTest {
storageService.initialize();
allocator = new StatusListIndexAllocator();
+ allocator.setId("test");
allocator.setStorage(storageService);
- allocator.setMaxIndex(TEST_MAX_INDEX);
+ allocator.setPermutationKey(TEST_KEY);
allocator.initialize();
requestCtx = new RequestContextBuilder().buildRequestContext();
@@ -78,14 +90,7 @@ public class AllocateIndexTest {
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();
+ action = newAction(allocator, EXPIRATION, 1, null);
}
@AfterMethod
@@ -100,6 +105,28 @@ public class AllocateIndexTest {
}
}
+ private AllocateIndex newAction(final StatusListIndexAllocator statusListAllocator, final Instant expiration,
+ final Integer count, final String pathPrefix) throws ComponentInitializationException {
+ AllocateIndex a = new AllocateIndex();
+ a.setStatusListAllocator(statusListAllocator);
+ a.setExpirationLookupStrategy(prc -> expiration);
+ a.setIndexCountLookupStrategy(prc -> count);
+ if (pathPrefix != null) {
+ a.setPathPrefix(pathPrefix);
+ }
+ a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+ public HttpServletRequest get() {
+ return httpRequest;
+ }
+ });
+ a.initialize();
+ return a;
+ }
+
+ private StatusListContext outboundStatusListContext() {
+ return profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+ }
+
@Test
public void testInitFailsWithoutAllocator() {
AllocateIndex a = new AllocateIndex();
@@ -110,6 +137,30 @@ public class AllocateIndexTest {
}
}
+ @Test
+ public void testInitFailsWithoutExpirationStrategy() {
+ AllocateIndex a = new AllocateIndex();
+ a.setStatusListAllocator(allocator);
+ a.setIndexCountLookupStrategy(prc -> 1);
+ try {
+ a.initialize();
+ Assert.fail("Missing expiration lookup strategy should have caused initialization failure");
+ } catch (ComponentInitializationException e) {
+ }
+ }
+
+ @Test
+ public void testInitFailsWithoutCountStrategy() {
+ AllocateIndex a = new AllocateIndex();
+ a.setStatusListAllocator(allocator);
+ a.setExpirationLookupStrategy(prc -> EXPIRATION);
+ try {
+ a.initialize();
+ Assert.fail("Missing index count lookup strategy should have caused initialization failure");
+ } catch (ComponentInitializationException e) {
+ }
+ }
+
@Test
public void testInitFailsWithNullAllocator() {
AllocateIndex a = new AllocateIndex();
@@ -123,67 +174,68 @@ public class AllocateIndexTest {
@Test
public void testSuccess() {
ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
- StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+ StatusListContext ctx = outboundStatusListContext();
Assert.assertNotNull(ctx);
- Assert.assertEquals(ctx.getIndex(), 0);
- Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+ Assert.assertEquals(ctx.getStatusListUri(), EXPECTED_URI);
+ Assert.assertEquals(ctx.getIndices().size(), 1);
+ Assert.assertNotNull(ctx.getIndex(0));
+ Assert.assertNull(ctx.getIndex(1));
}
@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");
+ public void testBatchAllocatesOneSlotPerCredential() throws ComponentInitializationException {
+ AllocateIndex a = newAction(allocator, EXPIRATION, 3, null);
+ ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
+ StatusListContext ctx = outboundStatusListContext();
+ Assert.assertEquals(ctx.getIndices().size(), 3);
+ Assert.assertEquals(new HashSet<>(ctx.getIndices()).size(), 3, "Batch must not reuse a slot");
+ // One expiration means one list, so a batch shares its uri.
+ Assert.assertEquals(ctx.getStatusListUri(), EXPECTED_URI);
+ }
- 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 testBucketChosenFromExpiration() throws ComponentInitializationException {
+ AllocateIndex a = newAction(allocator, LATER_EXPIRATION, 1, null);
+ ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
+ Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+ "https://issuer.example.org/idp/profile/oauth/status-list/list?id=2027-03");
}
@Test
- public void testRolloverUrlPath() throws IOException {
- for (int i = 0; i < TEST_MAX_INDEX; i++) {
- allocator.allocate();
- }
+ public void testSequentialAllocationsDoNotRepeat() {
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");
+ Long first = outboundStatusListContext().getIndex(0);
+
+ profileRequestCtx.setOutboundMessageContext(new MessageContext());
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+ StatusListContext secondCtx = outboundStatusListContext();
+
+ Assert.assertNotEquals(secondCtx.getIndex(0), first);
+ Assert.assertEquals(secondCtx.getStatusListUri(), EXPECTED_URI);
}
@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();
+ AllocateIndex a = newAction(allocator, EXPIRATION, 1, "/custom/status");
ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
- StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
- Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/custom/status/0");
+ Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+ "https://issuer.example.org/custom/status?id=2027-02");
}
@Test
- public void testNonDefaultPortIncludedInUrl() throws ComponentInitializationException {
+ public void testNonDefaultPortIncludedInUrl() {
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");
+ Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+ "https://issuer.example.org:8443/idp/profile/oauth/status-list/list?id=2027-02");
}
@Test
public void testNoHttpServletRequest() throws ComponentInitializationException {
AllocateIndex a = new AllocateIndex();
a.setStatusListAllocator(allocator);
+ a.setExpirationLookupStrategy(prc -> EXPIRATION);
+ a.setIndexCountLookupStrategy(prc -> 1);
a.initialize();
ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
}
@@ -194,27 +246,38 @@ public class AllocateIndexTest {
ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
}
+ @Test
+ public void testNoExpirationAvailable() throws ComponentInitializationException {
+ AllocateIndex a = newAction(allocator, null, 1, null);
+ ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+ }
+
+ @Test
+ public void testNoCountAvailable() throws ComponentInitializationException {
+ AllocateIndex a = newAction(allocator, EXPIRATION, null, null);
+ ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+ }
+
+ @Test
+ public void testNonPositiveCount() throws ComponentInitializationException {
+ AllocateIndex a = newAction(allocator, EXPIRATION, 0, null);
+ ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+ }
+
@Test
public void testAllocatorIoError() throws ComponentInitializationException {
StatusListIndexAllocator failing = new StatusListIndexAllocator() {
@Override
- public StatusListIndex allocate() throws IOException {
+ public List<Long> allocate(final StatusListBucket bucket, final int count) throws IOException {
throw new IOException("simulated storage failure");
}
};
+ failing.setId("failing");
failing.setStorage(storageService);
- failing.setMaxIndex(TEST_MAX_INDEX);
+ failing.setPermutationKey(TEST_KEY);
failing.initialize();
- AllocateIndex a = new AllocateIndex();
- a.setStatusListAllocator(failing);
- a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
- public HttpServletRequest get() {
- return httpRequest;
- }
- });
- a.initialize();
-
+ AllocateIndex a = newAction(failing, EXPIRATION, 1, null);
ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.IO_ERROR);
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java
new file mode 100644
index 0000000..acefc37
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link FeistelIndexPermutation}
+ */
+public class FeistelIndexPermutationTest {
+
+ /** Narrow width, so the whole slot space can be walked. */
+ private static final int TEST_BITS = 8;
+
+ private static final int TEST_SPACE = 256;
+
+ private static final byte[] KEY = "test-permutation-key".getBytes(StandardCharsets.UTF_8);
+
+ private static final String TWEAK = "2027-02";
+
+ @Test
+ public void testConstructorConstraints() {
+ try {
+ new FeistelIndexPermutation(7, KEY);
+ Assert.fail("Odd width should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ try {
+ new FeistelIndexPermutation(6, KEY);
+ Assert.fail("Width below 8 should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ try {
+ new FeistelIndexPermutation(34, KEY);
+ Assert.fail("Width above 32 should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ try {
+ new FeistelIndexPermutation(TEST_BITS, new byte[0]);
+ Assert.fail("Empty key should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ }
+
+ @Test
+ public void testIsBijectionOverWholeSpace() {
+ FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+ Set<Long> indices = new HashSet<>();
+ for (long counter = 0; counter < TEST_SPACE; counter++) {
+ long index = permutation.permute(counter, TWEAK);
+ Assert.assertTrue(index >= 0 && index < TEST_SPACE, "Index must stay inside the slot space");
+ indices.add(index);
+ }
+
+ Assert.assertEquals(indices.size(), TEST_SPACE, "Permutation must visit every slot exactly once");
+ }
+
+ @Test
+ public void testIsBijectionOverTheSpaceInUse() {
+ final FeistelIndexPermutation permutation = new FeistelIndexPermutation(StatusListParameters.INDEX_BITS, KEY);
+ final long capacity = StatusListParameters.CAPACITY;
+
+ // One bit per slot, so a million slots is 128 kilobytes and no boxing.
+ final long[] seen = new long[(int) (capacity / 64)];
+
+ for (long counter = 0; counter < capacity; counter++) {
+ final long index = permutation.permute(counter, TWEAK);
+
+ if (index < 0 || index >= capacity) {
+ Assert.fail("Counter " + counter + " gave index " + index + " outside the list");
+ }
+
+ final int word = (int) (index >>> 6);
+ final long bit = 1L << (index & 63);
+ if ((seen[word] & bit) != 0) {
+ Assert.fail("Slot " + index + " was hit twice, at counter " + counter);
+ }
+ seen[word] |= bit;
+ }
+
+ for (int word = 0; word < seen.length; word++) {
+ if (seen[word] != -1L) {
+ Assert.fail("Slots of word " + word + " were not all hit, got " + Long.toHexString(seen[word]));
+ }
+ }
+ }
+
+ @Test
+ public void testDeterministic() {
+ FeistelIndexPermutation first = new FeistelIndexPermutation(TEST_BITS, KEY);
+ FeistelIndexPermutation second = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+ for (long counter = 0; counter < TEST_SPACE; counter++) {
+ Assert.assertEquals(second.permute(counter, TWEAK), first.permute(counter, TWEAK),
+ "Same key must survive a restart with the same mapping");
+ }
+ }
+
+ @Test
+ public void testTweakSeparatesBuckets() {
+ FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+ int shared = 0;
+ for (long counter = 0; counter < TEST_SPACE; counter++) {
+ if (permutation.permute(counter, TWEAK) == permutation.permute(counter, "2027-03")) {
+ shared++;
+ }
+ }
+ Assert.assertTrue(shared < TEST_SPACE / 4, "Buckets must not permute alike, shared placements: " + shared);
+ }
+
+ @Test
+ public void testKeySeparatesPermutations() {
+ FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+ FeistelIndexPermutation other = new FeistelIndexPermutation(TEST_BITS,
+ "another-permutation-key".getBytes(StandardCharsets.UTF_8));
+
+ int shared = 0;
+ for (long counter = 0; counter < TEST_SPACE; counter++) {
+ if (permutation.permute(counter, TWEAK) == other.permute(counter, TWEAK)) {
+ shared++;
+ }
+ }
+ Assert.assertTrue(shared < TEST_SPACE / 4, "Keys must not permute alike, shared placements: " + shared);
+ }
+
+ @Test
+ public void testConsecutiveCountersAreNotConsecutiveIndices() {
+ FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+ int adjacent = 0;
+ for (long counter = 0; counter < TEST_SPACE - 1; counter++) {
+ if (permutation.permute(counter + 1, TWEAK) - permutation.permute(counter, TWEAK) == 1) {
+ adjacent++;
+ }
+ }
+ Assert.assertTrue(adjacent < TEST_SPACE / 8, "Indices track allocation order, adjacent pairs: " + adjacent);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java
new file mode 100644
index 0000000..2f504fa
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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 java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link StatusListAssignmentStore}
+ */
+public class StatusListAssignmentStoreTest {
+
+ private MemoryStorageService storageService;
+
+ private StatusListAssignmentStore store;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ storageService = new MemoryStorageService();
+ storageService.setId("test");
+ storageService.initialize();
+
+ store = newStore();
+ }
+
+ @AfterMethod
+ protected void tearDown() {
+ store.destroy();
+ store = null;
+ storageService.destroy();
+ storageService = null;
+ }
+
+ private StatusListAssignmentStore newStore() throws Exception {
+ final StatusListAssignmentStore created = new StatusListAssignmentStore();
+ created.setId("test");
+ created.setStorage(storageService);
+ created.initialize();
+ return created;
+ }
+
+ private StatusListAssignment record(final String id, final List<Long> indices) {
+ final StatusListAssignment assignment = new StatusListAssignment();
+ assignment.setId(id);
+ assignment.setIndices(indices);
+ assignment.setExpires(1802769445L);
+ assignment.setDetails(Map.of("format", "vc+sd-jwt", "claims", Map.of("given_name", "Alice")));
+ return assignment;
+ }
+
+ @Test
+ public void testNothingStoredReadsEmpty() throws IOException {
+ final StatusListAssignments page = store.read(0, 100);
+
+ Assert.assertTrue(page.getRecords().isEmpty());
+ Assert.assertEquals(page.getHighWater(), 0);
+ Assert.assertEquals(page.getNext(), 0, "With nothing written there is nowhere to continue from");
+ }
+
+ @Test
+ public void testStoreAndRead() throws IOException {
+ store.store(record("2027-02", List.of(14314L)));
+
+ final StatusListAssignments page = store.read(0, 100);
+ Assert.assertEquals(page.getRecords().size(), 1);
+ Assert.assertEquals(page.getHighWater(), 1);
+ Assert.assertEquals(page.getNext(), 1);
+
+ final StatusListAssignment read = page.getRecords().get(0);
+ Assert.assertEquals(read.getSeq(), 0, "A record carries the position it was stored at");
+ Assert.assertEquals(read.getId(), "2027-02");
+ Assert.assertEquals(read.getIndices(), List.of(14314L));
+ Assert.assertEquals(read.getExpires(), 1802769445L);
+ Assert.assertEquals(((Map<?, ?>) read.getDetails().get("claims")).get("given_name"), "Alice");
+ }
+
+ @Test
+ public void testPositionsAreTakenInOrder() throws IOException {
+ store.store(record("2027-02", List.of(1L)));
+ store.store(record("2027-02", List.of(2L)));
+ store.store(record("2027-03", List.of(3L)));
+
+ final StatusListAssignments page = store.read(0, 100);
+ Assert.assertEquals(page.getRecords().size(), 3);
+ Assert.assertEquals(page.getRecords().get(0).getSeq(), 0);
+ Assert.assertEquals(page.getRecords().get(1).getSeq(), 1);
+ Assert.assertEquals(page.getRecords().get(2).getSeq(), 2);
+ Assert.assertEquals(page.getHighWater(), 3);
+ }
+
+ @Test
+ public void testPagingContinuesWhereItStopped() throws IOException {
+ for (int i = 0; i < 5; i++) {
+ store.store(record("2027-02", List.of((long) i)));
+ }
+
+ final StatusListAssignments first = store.read(0, 2);
+ Assert.assertEquals(first.getRecords().size(), 2);
+ Assert.assertEquals(first.getNext(), 2);
+ Assert.assertEquals(first.getHighWater(), 5);
+
+ final StatusListAssignments second = store.read(first.getNext(), 2);
+ Assert.assertEquals(second.getRecords().size(), 2);
+ Assert.assertEquals(second.getRecords().get(0).getSeq(), 2);
+ Assert.assertEquals(second.getNext(), 4);
+
+ final StatusListAssignments third = store.read(second.getNext(), 2);
+ Assert.assertEquals(third.getRecords().size(), 1);
+ Assert.assertEquals(third.getNext(), third.getHighWater(), "Reaching the high water means caught up");
+ }
+
+ @Test
+ public void testGapsAreSkipped() throws IOException {
+ store.store(record("2027-02", List.of(1L)));
+ store.store(record("2027-02", List.of(2L)));
+ store.store(record("2027-02", List.of(3L)));
+
+ storageService.delete(StatusListAssignmentStore.class.getName(), "1");
+
+ final StatusListAssignments page = store.read(0, 100);
+ Assert.assertEquals(page.getRecords().size(), 2);
+ Assert.assertEquals(page.getRecords().get(0).getSeq(), 0);
+ Assert.assertEquals(page.getRecords().get(1).getSeq(), 2);
+ Assert.assertEquals(page.getNext(), 3);
+ }
+
+ @Test
+ public void testReadingBeyondWhatWasWritten() throws IOException {
+ store.store(record("2027-02", List.of(1L)));
+
+ final StatusListAssignments page = store.read(50, 100);
+ Assert.assertTrue(page.getRecords().isEmpty());
+ Assert.assertEquals(page.getNext(), 50, "A collector asking past the end stays where it was");
+ Assert.assertEquals(page.getHighWater(), 1);
+ }
+
+ @Test
+ public void testSequenceSurvivesRestart() throws Exception {
+ store.store(record("2027-02", List.of(1L)));
+ store.destroy();
+
+ store = newStore();
+ store.store(record("2027-02", List.of(2L)));
+
+ final StatusListAssignments page = store.read(0, 100);
+ Assert.assertEquals(page.getRecords().size(), 2);
+ Assert.assertEquals(page.getRecords().get(1).getSeq(), 1);
+ }
+
+ @Test
+ public void testRecordExpiresAfterRetention() throws Exception {
+ final StatusListAssignmentStore shortLived = new StatusListAssignmentStore();
+ shortLived.setId("shortLived");
+ shortLived.setStorage(storageService);
+ shortLived.setRetention(Duration.ofDays(1));
+ shortLived.initialize();
+
+ try {
+ final Instant before = Instant.now();
+ shortLived.store(record("2027-02", List.of(1L)));
+ final Instant after = Instant.now();
+
+ final StorageRecord<String> stored = storageService.read(StatusListAssignmentStore.class.getName(), "0");
+ Assert.assertNotNull(stored.getExpiration(), "A record kept for ever would hold its claims for ever");
+ Assert.assertTrue(
+ stored.getExpiration() >= before.plus(Duration.ofDays(1)).toEpochMilli()
+ && stored.getExpiration() <= after.plus(Duration.ofDays(1)).toEpochMilli(),
+ "The record does not expire after the retention configured");
+ } finally {
+ shortLived.destroy();
+ }
+ }
+
+ @Test
+ public void testSequenceDoesNotExpire() throws IOException {
+ store.store(record("2027-02", List.of(1L)));
+
+ final StorageRecord<String> sequence = storageService.read(StatusListAssignmentStore.class.getName(),
+ "sequence");
+ Assert.assertNotNull(sequence);
+ Assert.assertNull(sequence.getExpiration(),
+ "Losing the sequence would restart it and write records over one another");
+ }
+
+ @Test
+ public void testBounds() throws IOException {
+ try {
+ store.read(-1, 10);
+ Assert.fail("A negative position should have been rejected");
+ } catch (Exception e) {
+ }
+ try {
+ store.read(0, 0);
+ Assert.fail("A limit of zero should have been rejected");
+ } catch (Exception e) {
+ }
+ }
+
+ @Test
+ public void testRetentionMustBePositive() throws Exception {
+ final StatusListAssignmentStore created = new StatusListAssignmentStore();
+ try {
+ created.setRetention(Duration.ZERO);
+ Assert.fail("A retention of zero should have been rejected");
+ } catch (Exception e) {
+ }
+ }
+
+}
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
index d54cb2f..13fa1eb 100644
--- 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
@@ -17,8 +17,14 @@
package org.geant.shibboleth.plugin.oauth.statuslist.storage;
import java.io.IOException;
-
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.opensaml.storage.StorageRecord;
import org.opensaml.storage.impl.MemoryStorageService;
import org.opensaml.storage.impl.client.ClientStorageService;
import org.testng.Assert;
@@ -35,17 +41,20 @@ public class StatusListIndexAllocatorTest {
private StatusListIndexAllocator allocator;
- private static final int TEST_MAX_INDEX = 1000;
+ private static final String TEST_KEY = "test-permutation-key";
+
+ private static final StatusListBucket BUCKET = new StatusListBucket("2027-02",
+ Instant.parse("2027-03-01T00:00:00Z"));
+
+ private static final StatusListBucket OTHER_BUCKET = new StatusListBucket("2027-03",
+ Instant.parse("2027-04-01T00:00:00Z"));
@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();
+ allocator = newAllocator();
}
@AfterMethod
@@ -56,6 +65,15 @@ public class StatusListIndexAllocatorTest {
storageService = null;
}
+ private StatusListIndexAllocator newAllocator() throws Exception {
+ StatusListIndexAllocator newAllocator = new StatusListIndexAllocator();
+ newAllocator.setId("test");
+ newAllocator.setStorage(storageService);
+ newAllocator.setPermutationKey(TEST_KEY);
+ newAllocator.initialize();
+ return newAllocator;
+ }
+
@Test
public void testInit() {
StatusListIndexAllocator listAllocator = new StatusListIndexAllocator();
@@ -70,8 +88,20 @@ public class StatusListIndexAllocatorTest {
} catch (Exception e) {
}
try {
- listAllocator.setMaxIndex(999);
- Assert.fail("maxIndex below 1000 should have caused constraint violation");
+ listAllocator.setMaxSlotsPerAllocation(0);
+ Assert.fail("Non-positive maxSlotsPerAllocation should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ }
+
+ @Test
+ public void testInitRequiresPermutationKey() throws Exception {
+ StatusListIndexAllocator listAllocator = new StatusListIndexAllocator();
+ listAllocator.setId("test");
+ listAllocator.setStorage(storageService);
+ try {
+ listAllocator.initialize();
+ Assert.fail("Missing permutation key should have prevented initialization");
} catch (Exception e) {
}
}
@@ -82,55 +112,99 @@ public class StatusListIndexAllocatorTest {
}
@Test
- public void testFirstAllocation() throws IOException {
- StatusListIndex first = allocator.allocate();
- Assert.assertEquals(first.index(), 0);
- Assert.assertEquals(first.uriIndex(), 0);
+ public void testSingleAllocation() throws IOException {
+ List<Long> indices = allocator.allocate(BUCKET, 1);
+ Assert.assertEquals(indices.size(), 1);
+ Assert.assertTrue(indices.get(0) >= 0 && indices.get(0) < allocator.getCapacity());
+ }
+
+ @Test
+ public void testBatchAllocationIsDistinctAndNotSequential() throws IOException {
+ List<Long> indices = allocator.allocate(BUCKET, 3);
+ Assert.assertEquals(indices.size(), 3);
+ Assert.assertEquals(new HashSet<>(indices).size(), 3, "Batch must not reuse a slot");
+ for (Long index : indices) {
+ Assert.assertTrue(index >= 0 && index < allocator.getCapacity());
+ }
+ Assert.assertNotEquals(indices, List.of(0L, 1L, 2L), "Indices must not expose allocation order");
+ }
+
+ @Test
+ public void testAllocationsNeverRepeatWithinBucket() throws IOException {
+ final int allocations = 500;
+ Set<Long> seen = new HashSet<>();
+ for (int i = 0; i < allocations; i++) {
+ seen.addAll(allocator.allocate(BUCKET, 1));
+ }
+ Assert.assertEquals(seen.size(), allocations, "No slot may be handed out twice");
+ }
+
+ @Test
+ public void testStateExpiresAfterBucketEnd() throws IOException {
+ allocator.allocate(BUCKET, 1);
+
+ StorageRecord<String> record = storageService.read(StatusListIndexAllocator.class.getName(), BUCKET.id());
+ Assert.assertNotNull(record);
+ Assert.assertEquals(record.getExpiration(), Long.valueOf(BUCKET.end().plus(Duration.ofDays(7)).toEpochMilli()));
+ }
+
+ @Test
+ public void testBucketsAllocateIndependently() throws IOException {
+ Long first = allocator.allocate(BUCKET, 1).get(0);
+ Long other = allocator.allocate(OTHER_BUCKET, 1).get(0);
+ // Both buckets start from counter zero, but permute under their own identifier.
+ Assert.assertNotEquals(first, other, "Buckets must not share a permutation");
+ }
+
+ @Test
+ public void testExhaustedBucket() throws IOException {
+ // Set the counter to capacity instead of allocating a million slots.
+ storageService.create(StatusListIndexAllocator.class.getName(), BUCKET.id(),
+ Long.toString(allocator.getCapacity()), null);
+ try {
+ allocator.allocate(BUCKET, 1);
+ Assert.fail("Exhausted bucket should have failed allocation");
+ } catch (IOException e) {
+ }
}
@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);
+ public void testBatchStraddlingCapacityIsRejectedWhole() throws IOException {
+ storageService.create(StatusListIndexAllocator.class.getName(), BUCKET.id(),
+ Long.toString(allocator.getCapacity() - 1), null);
+ try {
+ allocator.allocate(BUCKET, 2);
+ Assert.fail("Batch exceeding remaining capacity should have failed allocation");
+ } catch (IOException e) {
+ }
+ // The rejected batch must not have consumed the last slot.
+ Assert.assertEquals(allocator.allocate(BUCKET, 1).size(), 1);
}
@Test
- public void testRollover() throws IOException {
- StatusListIndex last = null;
- for (int i = 0; i < TEST_MAX_INDEX; i++) {
- last = allocator.allocate();
+ public void testCountBounds() throws IOException {
+ try {
+ allocator.allocate(BUCKET, 0);
+ Assert.fail("Zero count should have caused constraint violation");
+ } catch (Exception e) {
+ }
+ try {
+ allocator.allocate(BUCKET, allocator.getMaxSlotsPerAllocation() + 1);
+ Assert.fail("Count above maxSlotsPerAllocation should have caused constraint violation");
+ } catch (Exception e) {
}
- 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();
+ Set<Long> seen = new HashSet<>(allocator.allocate(BUCKET, 2));
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);
+ allocator = newAllocator();
+
+ List<Long> next = allocator.allocate(BUCKET, 1);
+ Assert.assertEquals(next.size(), 1);
+ Assert.assertFalse(seen.contains(next.get(0)), "Restart must not hand out an already allocated slot");
}
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java
new file mode 100644
index 0000000..927b4bb
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.zip.Inflater;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.util.Base64URL;
+
+/**
+ * Tests for {@link StatusListStore}
+ */
+public class StatusListStoreTest {
+
+ private MemoryStorageService storageService;
+
+ private StatusListStore store;
+
+ private static final StatusListBucket BUCKET = new StatusListBucket("2027-02",
+ Instant.parse("2027-03-01T00:00:00Z"));
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ storageService = new MemoryStorageService();
+ storageService.setId("test");
+ storageService.initialize();
+
+ store = new StatusListStore();
+ store.setId("test");
+ store.setStorage(storageService);
+ store.initialize();
+ }
+
+ @AfterMethod
+ protected void tearDown() {
+ store.destroy();
+ store = null;
+ storageService.destroy();
+ storageService = null;
+ }
+
+ private byte[] published(final String encoded) throws Exception {
+ final Inflater inflater = new Inflater();
+ try {
+ inflater.setInput(new Base64URL(encoded).decode());
+ final ByteArrayOutputStream inflated = new ByteArrayOutputStream();
+ final byte[] buffer = new byte[8192];
+ while (!inflater.finished()) {
+ inflated.write(buffer, 0, inflater.inflate(buffer));
+ }
+ return inflated.toByteArray();
+ } finally {
+ inflater.end();
+ }
+ }
+
+ @Test
+ public void testNothingRevokedHoldsNoList() throws IOException {
+ Assert.assertNull(store.read(BUCKET));
+ }
+
+ @Test
+ public void testRevokeShowsInPublishedList() throws Exception {
+ store.revoke(BUCKET, 14314);
+
+ final String encoded = store.read(BUCKET);
+ Assert.assertNotNull(encoded);
+
+ final byte[] list = published(encoded);
+ Assert.assertEquals(list.length, StatusListParameters.BYTE_LENGTH,
+ "A published list is always of the full size, whatever is revoked");
+ Assert.assertFalse(StatusListCodec.isValid(list, 14314));
+ Assert.assertTrue(StatusListCodec.isValid(list, 14313));
+ Assert.assertTrue(StatusListCodec.isValid(list, 14315));
+ }
+
+ @Test
+ public void testRevokeIsIdempotent() throws Exception {
+ store.revoke(BUCKET, 14314);
+ final String first = store.read(BUCKET);
+ store.revoke(BUCKET, 14314);
+
+ Assert.assertEquals(store.read(BUCKET), first, "Revoking twice leaves the list as it was");
+ }
+
+ @Test
+ public void testRevocationsAcrossPiecesAssembleIntoOneList() throws Exception {
+ final long lastOfFirst = StatusListParameters.CHUNK_CAPACITY - 1;
+ final long firstOfSecond = StatusListParameters.CHUNK_CAPACITY;
+ final long lastOfList = StatusListParameters.CAPACITY - 1;
+
+ store.revoke(BUCKET, 0);
+ store.revoke(BUCKET, lastOfFirst);
+ store.revoke(BUCKET, firstOfSecond);
+ store.revoke(BUCKET, lastOfList);
+
+ final byte[] list = published(store.read(BUCKET));
+ Assert.assertFalse(StatusListCodec.isValid(list, 0));
+ Assert.assertFalse(StatusListCodec.isValid(list, lastOfFirst));
+ Assert.assertFalse(StatusListCodec.isValid(list, firstOfSecond));
+ Assert.assertFalse(StatusListCodec.isValid(list, lastOfList));
+ Assert.assertTrue(StatusListCodec.isValid(list, 1));
+ Assert.assertTrue(StatusListCodec.isValid(list, firstOfSecond + 1));
+ }
+
+ @Test
+ public void testOnlyThePieceHoldingTheSlotIsWritten() throws IOException {
+ store.revoke(BUCKET, 0);
+
+ Assert.assertNotNull(storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0"));
+ Assert.assertNull(storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#1"),
+ "A piece nothing was revoked from should not have been written");
+ }
+
+ @Test
+ public void testStoredPieceIsOfFixedSize() throws IOException {
+ store.revoke(BUCKET, 0);
+ final StorageRecord<String> record = storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0");
+
+ Assert.assertEquals(record.getValue().length(),
+ StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length());
+ }
+
+ @Test
+ public void testStateExpiresAfterBucketEnd() throws IOException {
+ store.revoke(BUCKET, 14314);
+ final StorageRecord<String> record = storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0");
+
+ Assert.assertEquals(record.getExpiration(),
+ Long.valueOf(BUCKET.end().plus(StatusListParameters.RETENTION).toEpochMilli()));
+ }
+
+ @Test
+ public void testBucketsAreSeparate() throws Exception {
+ final StatusListBucket other = new StatusListBucket("2027-03", Instant.parse("2027-04-01T00:00:00Z"));
+
+ store.revoke(BUCKET, 14314);
+
+ Assert.assertNull(store.read(other), "Revoking in one list must not touch another");
+ }
+
+ @Test
+ public void testSlotOutsideList() {
+ try {
+ store.revoke(BUCKET, StatusListParameters.CAPACITY);
+ Assert.fail("A slot beyond the list should have been rejected");
+ } catch (Exception e) {
+ }
+ try {
+ store.revoke(BUCKET, -1);
+ Assert.fail("A negative slot should have been rejected");
+ } catch (Exception e) {
+ }
+ }
+
+ @Test
+ public void testInitRequiresStorage() throws Exception {
+ final StatusListStore uninitialized = new StatusListStore();
+ uninitialized.setId("test");
+ try {
+ uninitialized.initialize();
+ Assert.fail("Missing storage should have prevented initialization");
+ } catch (Exception e) {
+ }
+ }
+
+ @Test
+ public void testRetentionCoversTheWholeOfABucket() {
+ Assert.assertTrue(StatusListParameters.RETENTION.compareTo(Duration.ZERO) > 0);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json b/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
index 64f5740..65a70c9 100644
--- a/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
+++ b/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
@@ -71,8 +71,7 @@
},
"credential_definition": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
- "type": ["VerifiableCredential", "GeantIncubatorDiploma2"],
- "issuer": "https://issuer.example.org"
+ "type": ["VerifiableCredential", "GeantIncubatorDiploma2"]
},
"credential_metadata": {
"display": [
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list