[java-idp-plugin-vci] 01/01: Credential Configuration to include per credential lifetime and status list inclusion
Codeberg
noreply at shibboleth.net
Wed Sep 9 07:37:47 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch dev/CredentialConf
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/7b40bca0a9da9b4d9c362161297f3eed864f8978
commit 7b40bca0a9da9b4d9c362161297f3eed864f8978
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 9 10:37:24 2026 +0300
Credential Configuration to include per credential lifetime and status list inclusion
---
README.md | 133 +++++++++++-
.../credential/CredentialConfiguration.java | 56 ++++-
.../credential/CredentialConfigurations.java | 78 ++++++-
.../CredentialIssuanceConfiguration.java | 185 ++++++++++++++++
.../plugin/openidvci/credential/ValidityStart.java | 236 +++++++++++++++++++++
.../credential/CredentialConfigurationsTest.java | 40 ++++
.../CredentialIssuanceConfigurationTest.java | 162 ++++++++++++++
.../openidvci/credential/ValidityStartTest.java | 138 ++++++++++++
.../messaging/context/CredentialsContext.java | 27 +++
.../openidvci/profile/impl/AddCredentialShell.java | 16 +-
.../profile/impl/ResolveCredentialLifetime.java | 53 ++++-
.../logic/StatusListInclusionPredicate.java | 71 +++++++
.../oauth/status-list/assign/assign-beans.xml | 8 +-
.../plugin/openidvci/conf/openid-vci.properties | 7 +
...systemCredentialConfigurationsResolverTest.java | 29 +++
.../CredentialConfigurationsValueResolverTest.java | 15 +-
.../impl/ResolveCredentialLifetimeTest.java | 153 +++++++++++++
.../logic/StatusListInclusionPredicateTest.java | 88 ++++++++
.../resources/conf/verifiable-credentials.json | 8 +
19 files changed, 1464 insertions(+), 39 deletions(-)
diff --git a/README.md b/README.md
index 0f1e995..dc490ad 100644
--- a/README.md
+++ b/README.md
@@ -279,6 +279,94 @@ If this feels like a lot of trouble, it is not needed at all when you do not iss
`vc+sd-jwt` credential. Leave the configuration of that credential out and the file goes with
it.
+#### Settings of your own, per credential
+
+Everything above is published exactly as you write it. The `_issuance_configuration` member is
+not. It states how long a credential is valid and whether it goes on a Status List, and it is
+dropped from the published `credential_configurations_supported`, so a wallet never sees it.
+
+It goes inside the credential's own configuration, next to `format` and `vct`:
+
+```json
+{
+ "credential_configurations_supported": {
+ "GeantIncubatorDiploma_SDJWT": {
+ "format": "dc+sd-jwt",
+ "vct": "GeantIncubatorDiploma_SDJWT",
+ "_issuance_configuration": {
+ "lifetime": "P365D",
+ "not_before": "2026-08-01T06:00Z",
+ "status_list": true
+ },
+ "credential_metadata": { "...": "..." }
+ }
+ }
+}
+```
+
+| Member | Type | Default | Description |
+|---|---|---|---|
+| `lifetime` | Duration | `credentialLifetime` of the profile configuration, `P180D` | Length of the validity window. |
+| `not_before` | Duration, moment or date | The instant of issuance | Where the validity window opens. |
+| `status_list` | Boolean | `openidvci.status-list.includeByDefault`, `true` | Whether the credential takes a Status List slot. |
+
+All three are optional and so is the member itself. A credential that states nothing behaves
+exactly as it did before you knew this member existed.
+
+The settings live in *metadata/verifiable-credentials.json* and they are re-read the same way
+the rest of the file is, within the bounds of `openidvci.credentialConfigurations.minRefreshDelay`
+and `…maxRefreshDelay`. Adding a credential and deciding its validity is one edit of one file
+and it needs no reload.
+
+##### The validity window
+
+**The window is `not_before` until `not_before` plus `lifetime`.** The lifetime is the length of
+the window, not a distance from the moment of issuance. With no `not_before` the window opens at
+issuance.
+
+Write `not_before` as one of these:
+
+| Value | The window opens | Use it for |
+|---|---|---|
+| `P30D`, `PT12H` | That much after the credential was issued | A window of the same length for everyone, whenever they ask |
+| `2026-08-01T06:00Z` | At exactly that minute, `Z` being UTC | A moment you decide, the start of a school year |
+| `2026-08-01T00:00+03:00` | The same, in the offset you state | A moment your users think of in local time |
+| `2026-08-01` | At midnight UTC of that day | The same, when the time of day does not matter |
+
+A date is midnight UTC, not midnight where you are. Finnish summer time is three hours ahead,
+so `2026-08-01` opens the window at 03:00 local time.
+
+A window that has already closed issues nothing. The log says so when the file is read, and a
+request for such a credential is refused.
+
+Months and years are not durations. `P1Y` and `P6M` are rejected, write them as days, `P365D`.
+This is the same rule the Duration settings of the IdP follow everywhere else.
+
+What ends up in the credential:
+
+| Format | Members carrying the window |
+|---|---|
+| `dc+sd-jwt` | `iat` is the instant of issuance, `exp` closes the window, and `nbf` opens it whenever that is not the instant of issuance |
+| `vc+sd-jwt` | The same three, and `validFrom` and `validUntil` as the same two ends of the window |
+
+A credential whose window opens at issuance carries no `nbf`.
+
+##### Whether a credential goes on a Status List
+
+`"status_list": false` keeps the credential off the list. It carries no `status` claim, it takes
+no slot, and there is nothing about it to revoke later. A credential too short lived to bother
+revoking is the usual reason to do this.
+
+A credential that states nothing goes on the list. Set
+`openidvci.status-list.includeByDefault` to `false` in *conf/openid-vci.properties* to turn that
+round and make the Status List something each credential opts into with `"status_list": true`.
+
+##### Which setting wins
+
+`lifetime` here overrides `credentialLifetime` of the profile configuration, including a
+`credentialLifetime` you set on one wallet in *conf/relying-party.xml*. Leave `lifetime` out of
+a credential whose validity you want to keep steering per wallet.
+
#### Notes
Do not set claims `"mandatory": true` while you are still experimenting. A mandatory claim
@@ -289,6 +377,16 @@ Claim nesting of `vc+sd-jwt` is done by the credential type when the credential
by the paths you send in a Credential Offer. The `credentialSubject` prefix belongs to this
file, not to the offer.
+`_issuance_configuration` is a member of this plugin's own and the specification knows nothing
+about it. The leading underscore is what keeps it that way. No specification of OpenID or of
+the IETF defines a JSON member that begins with one. A later version of the specification, or
+another extension of it, is unable to take the name from under you. The underscore also says at
+a glance that the member is not from the specification. Every other member of the file is.
+
+A member the plugin does not recognise inside `_issuance_configuration` is ignored. An
+unparsable value in one is not: it rejects the whole file, and the last file that did parse
+stays in use until you fix it.
+
### Signing keys
**File(s):** *conf/openid-vci-credentials.xml*, *conf/openid-vci.properties*
@@ -678,9 +776,11 @@ And you release it to the wallet in *conf/attribute-filter.xml*. This flow reads
Every credential this plugin issues carries a `status` claim that names one slot of a Status
List, as in [draft-ietf-oauth-status-list](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/).
-There is no per credential switch for it. The credential endpoint allocates a slot for every
-credential it signs, so the Status List is part of a working installation and not an extra you
-may skip.
+The credential endpoint allocates a slot for every credential it signs, unless the credential
+is told not to take one. The Status List is part of a working installation rather than an extra
+you opt into. Turning it off for one credential, or making it something each credential opts
+into, is in
+[Whether a credential goes on a Status List](#whether-a-credential-goes-on-a-status-list).
<details>
<summary>Example of the status claim of an issued credential</summary>
@@ -699,9 +799,13 @@ may skip.
</details>
Withdrawing that credential is setting bit 41337 of list `2027-03`. The list a credential lands
-in follows from the month the credential **expires**, not from the month it was issued. With
-the default (we will have per credential configuration for lifetime and nbf!) `credentialLifetime` of `P180D` a credential that is issued in September 2026
-belongs to the list of March 2027. One list has 1 048 576 slots.
+in follows from the month the credential **expires**, not from the month it was issued. With the
+default `credentialLifetime` of `P180D` a credential that is issued in September 2026 belongs to
+the list of March 2027. One list has 1 048 576 slots.
+
+The expiration is the one the credential actually gets, so a credential with a `lifetime` or a
+`not_before` of its own lands in the list its own window chooses. A `not_before` fixed to a date
+puts every holder of that credential in one list.
Three endpoints do the work and they have three different audiences:
@@ -1253,9 +1357,14 @@ issued per proof, each of them taking a status list slot of its own. Member
`credential_response_encryption` is parsed and written to the log and nothing else is done with
it, an encrypted credential response is not implemented.
+One request asks for one Credential Configuration, so the validity window and the Status List
+slots of a batch are those of that one credential. The window itself may come from the
+credential rather than from the settings below, see
+[Settings of your own, per credential](#settings-of-your-own-per-credential).
+
| Name | Type | Default | Description |
|---|---|---|---|
-| `credentialLifetime` | Duration | `P180D` | Validity of the credential. It also decides the Status List the credential lands in. |
+| `credentialLifetime` | Duration | `P180D` | Validity of the credential. It also decides the Status List the credential lands in. Not read for a credential whose configuration states a `lifetime` of its own. |
| `credentialSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.SigningConfiguration** | Signing configuration of the credential. |
| `proofSignatureValidationConfiguration` | SignatureValidationConfiguration | Bean named **openidvci.ProofSignatureValidationConfiguration** | Validation configuration of the key proof. |
| `proofClaimsValidator` | ClaimsValidator | Bean named **openidvci.DefaultProofBodyClaimsValidator** | Validator of the claims of the key proof. |
@@ -1505,7 +1614,7 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
| `openidvci.StorageService` | Bean ID | Bean named **shibboleth.StorageService** | Storage for Credential Offers. Requires server-side storage. |
| `openidvci.preAuthorizedCode.defaultLifetime` | Duration | `PT10M` | Lifetime of the pre-authorized code. |
| `openidvci.preAuthorizedCode.defaultLength` | Integer | `0` | Length of pre-authorized codes. `0` means untruncated and self-contained; `10` or more means a stored offer. |
-| `openidvci.credentialConfigurations.resource` | File pathname | `%{idp.home}/metadata/verifiable-credentials.json` | The Credential Configurations document. |
+| `openidvci.credentialConfigurations.resource` | File pathname | `%{idp.home}/metadata/verifiable-credentials.json` | The Credential Configurations document. Also carries the per credential settings of [Settings of your own, per credential](#settings-of-your-own-per-credential). |
| `openidvci.credentialConfigurations.minRefreshDelay` | Duration | `PT5M` | Lower bound on re-reading that file. |
| `openidvci.credentialConfigurations.maxRefreshDelay` | Duration | `PT4H` | Upper bound on re-reading that file. |
| `openidvci.issuerMetadata.template` | File pathname | `%{idp.home}/static/openid-credential-issuer.json` | Credential Issuer metadata document. |
@@ -1519,6 +1628,7 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
| `openidvci.signing.status-list.es.key` | File pathname | `%{idp.home}/credentials/openid-vci-status-list-signing-es.jwk` | Signing key of Status List Tokens. Must be EC. |
| `openidvci.signing.issuerMetadata.es.key` | File pathname | `%{idp.home}/credentials/openid-vci-issuer-metadata-signing-es.jwk` | Signing key of Credential Issuer metadata. Must be EC. |
| `openidvci.status-list.index.permutationKey` | String | `change_this_to_secret` | Key used in permuting status list indices. **Must be changed.** |
+| `openidvci.status-list.includeByDefault` | Boolean | `true` | Whether a credential whose configuration states no `status_list` takes a slot. `false` makes the Status List opt-in per credential. |
| `openidvci.status-list.token.defaultLifetime` | Duration | `PT1H` | Lifetime of Status List Tokens. |
| `openidvci.status-list.assignment.retention` | Duration | `P7D` | How long an assignment record is kept for collection after it was written. |
| `openidvci.status-list.StorageService` | Bean ID | Bean named **shibboleth.StorageService** | Storage for status list state. Requires server-side storage. |
@@ -1563,7 +1673,7 @@ DPoP settings are among them.
|---|---|---|---|
| `preAuthorizedCodeLifetime` | Duration | `PT10M` | Lifetime of the pre-authorized code. |
| `preAuthorizedCodeLength` | Integer | `0` | Length of the pre-authorized code. `0` is self-contained; `10` or more is stored. |
-| `credentialLifetime` | Duration | `P180D` | Validity of an issued credential. |
+| `credentialLifetime` | Duration | `P180D` | Validity of an issued credential. A credential whose configuration states a `lifetime` of its own does not read this. |
| `credentialSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.SigningConfiguration** | Signing configuration for issued credentials. |
| `proofSignatureValidationConfiguration` | SignatureValidationConfiguration | Bean named **openidvci.ProofSignatureValidationConfiguration** | Validation configuration for the wallet's key proof. |
| `proofClaimsValidator` | ClaimsValidator | Bean named **openidvci.DefaultProofBodyClaimsValidator** | Validator of the key proof's claims. |
@@ -1574,6 +1684,11 @@ DPoP settings are among them.
Each of them accepts also a `…LookupStrategy` variant that takes a `Function` returning the
value, in the usual Shibboleth way.
+`credentialLifetime` is the only one of them a Credential Configuration is able to override, and
+it does so with the `lifetime` of its `_issuance_configuration` member. See
+[Settings of your own, per credential](#settings-of-your-own-per-credential) for that member and
+for the validity window it decides.
+
### Beans
Beans that come with the plugin. You may refer to them or replace them. You may place your own
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
index cf2ba3e..2d732c8 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
@@ -27,6 +27,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonProperty.Access;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -40,6 +41,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
@JsonIgnoreProperties(ignoreUnknown = true)
public final class CredentialConfiguration {
+ /**
+ * Name of the member carrying the settings of this deployment for a Credential.
+ *
+ * The member is not part of the specification and it is not published,
+ * {@link CredentialConfigurations#getSupported()} drops it. The leading
+ * underscore keeps the name beyond the reach of the specification, which
+ * defines no member that begins with one.
+ */
+ @Nonnull
+ public static final String ISSUANCE_CONFIGURATION = "_issuance_configuration";
+
/** Identifies the format of this Credential. */
@Nonnull
@JsonProperty("format")
@@ -102,6 +114,17 @@ public final class CredentialConfiguration {
@JsonInclude(Include.NON_NULL)
private final String vct;
+ /**
+ * Settings of this deployment for this Credential.
+ *
+ * Declared write only, so that serializing a Credential Configuration is unable
+ * to disclose it even if something ever serializes one into the published
+ * document.
+ */
+ @Nullable
+ @JsonProperty(value = ISSUANCE_CONFIGURATION, access = Access.WRITE_ONLY)
+ private final CredentialIssuanceConfiguration issuanceConfiguration;
+
// Checkstyle: ParameterNumber OFF
/**
* Constructor.
@@ -126,23 +149,23 @@ public final class CredentialConfiguration {
* @param proofTypesSupported Specifics of the key proof(s)
* that the Credential Issuer
* supports
- * @param credentialMetadata Information relevant to the
- * usage and display of issued
- * Credentials
+ * @param credentialMetadata Information relevant to the usage
+ * and display of issued Credentials
* @param vct Verifiable Credential Type
* identifier (SD-JWT VC formats)
+ * @param issuanceConfiguration Settings of this deployment for
+ * this Credential
*/
@JsonCreator
private CredentialConfiguration(@JsonProperty("format") @Nonnull final String format,
@JsonProperty("scope") @Nullable final String scope,
- @JsonProperty("credential_signing_alg_values_supported") @Nullable
- final List<String> credentialSigningAlgValuesSupported,
- @JsonProperty("cryptographic_binding_methods_supported") @Nullable
- final List<String> cryptographicBindingMethodsSupported,
+ @JsonProperty("credential_signing_alg_values_supported") @Nullable final List<String> credentialSigningAlgValuesSupported,
+ @JsonProperty("cryptographic_binding_methods_supported") @Nullable final List<String> cryptographicBindingMethodsSupported,
@JsonProperty("credential_definition") @Nullable final CredentialDefinition credentialDefinition,
@JsonProperty("proof_types_supported") @Nullable final Map<String, ProofTypeSupported> proofTypesSupported,
@JsonProperty("credential_metadata") @Nullable final CredentialMetadata credentialMetadata,
- @JsonProperty("vct") @Nullable final String vct) {
+ @JsonProperty("vct") @Nullable final String vct,
+ @JsonProperty(ISSUANCE_CONFIGURATION) @Nullable final CredentialIssuanceConfiguration issuanceConfiguration) {
assert format != null;
this.format = format;
this.scope = scope;
@@ -152,9 +175,9 @@ public final class CredentialConfiguration {
this.proofTypesSupported = proofTypesSupported;
this.credentialMetadata = credentialMetadata;
this.vct = vct;
+ this.issuanceConfiguration = issuanceConfiguration;
// W3C family of credentials require credential definition
- if (credentialDefinition == null
- && "vc+sd-jwt".equals(format)) {
+ if (credentialDefinition == null && "vc+sd-jwt".equals(format)) {
throw new IllegalArgumentException("credential_definition is missing");
}
// SD-JWT VC family requires vct
@@ -251,6 +274,17 @@ public final class CredentialConfiguration {
return vct;
}
+ /**
+ * Get the settings of this deployment for this Credential.
+ *
+ * @return Settings of this deployment for this Credential, or null when the
+ * Credential Configuration states none
+ */
+ @Nullable
+ public CredentialIssuanceConfiguration getIssuanceConfiguration() {
+ return issuanceConfiguration;
+ }
+
/**
* Serialize object to json.
*
@@ -260,7 +294,7 @@ public final class CredentialConfiguration {
public String serialize() throws JsonProcessingException {
return new ObjectMapper().writeValueAsString(this);
}
-
+
/**
* Parse {@link CredentialConfiguration} from json string.
*
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
index e2ab738..8955d03 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurations.java
@@ -17,6 +17,8 @@
package org.geant.shibboleth.plugin.openidvci.credential;
import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -43,6 +45,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
* Both representations of the same source are kept. The parsed one instructs
* how a credential is formed, the source one is what gets published, so that a
* member this plugin does not model still reaches the wallet.
+ *
+ * The '{@value CredentialConfiguration#ISSUANCE_CONFIGURATION}' member is the
+ * exception. It states what a deployer decided about a credential, so it is
+ * parsed and then dropped from the source that gets published.
*/
public final class CredentialConfigurations {
@@ -54,6 +60,10 @@ public final class CredentialConfigurations {
@Nonnull
private static final ObjectMapper MAPPER = new ObjectMapper();
+ /** How long before a fixed validity window closes to start warning of it. */
+ @Nonnull
+ private static final Duration CLOSING_WINDOW_WARNING = Duration.ofDays(30);
+
/** Credential configuration content. */
@Nonnull
private final Map<String, CredentialConfiguration> credentialConfigurations;
@@ -110,8 +120,8 @@ public final class CredentialConfigurations {
@Nonnull
public static CredentialConfigurations parse(@Nonnull final byte[] bytes) throws IOException {
- final Map<String, Object> document =
- MAPPER.readValue(bytes, new TypeReference<LinkedHashMap<String, Object>>() {
+ final Map<String, Object> document = MAPPER.readValue(bytes,
+ new TypeReference<LinkedHashMap<String, Object>>() {
});
if (document == null) {
throw new IOException("Credential configuration is not a JSON object");
@@ -136,10 +146,70 @@ public final class CredentialConfigurations {
log.debug("Parsed {} credential configuration(s): {}", parsed.size(), parsed.keySet());
+ warnOfClosingWindows(parsed);
+
@SuppressWarnings("unchecked")
final Map<String, Object> source = (Map<String, Object>) configurations;
- return new CredentialConfigurations(Collections.unmodifiableMap(parsed),
- Collections.unmodifiableMap(source));
+ return new CredentialConfigurations(Collections.unmodifiableMap(parsed), published(source));
+ }
+
+ /**
+ * Copy the source into what gets published, without the members that are
+ * settings of this deployment.
+ *
+ * The private member is dropped here, where the source is read, so that no path
+ * that publishes the document is able to disclose it.
+ *
+ * @param source Credential Configurations as they were written
+ * @return what gets published, in the order it was written
+ */
+ @Nonnull
+ private static Map<String, Object> published(@Nonnull final Map<String, Object> source) {
+
+ final Map<String, Object> result = new LinkedHashMap<>(source.size());
+ for (final Map.Entry<String, Object> entry : source.entrySet()) {
+ final Object configuration = entry.getValue();
+ if (configuration instanceof Map<?, ?> members
+ && members.containsKey(CredentialConfiguration.ISSUANCE_CONFIGURATION)) {
+ final Map<Object, Object> copy = new LinkedHashMap<>(members);
+ copy.remove(CredentialConfiguration.ISSUANCE_CONFIGURATION);
+ result.put(entry.getKey(), Collections.unmodifiableMap(copy));
+ } else {
+ result.put(entry.getKey(), configuration);
+ }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ /**
+ * Warn of Credential Configurations whose validity window is fixed on the
+ * calendar and has closed, or closes soon.
+ *
+ * A fixed window is a value that goes stale and a deployer has to move it. A
+ * request for a credential of a closed window fails, this says so already when
+ * the document is read.
+ *
+ * @param configurations parsed Credential Configurations
+ */
+ private static void warnOfClosingWindows(@Nonnull final Map<String, CredentialConfiguration> configurations) {
+
+ final Instant now = Instant.now();
+ for (final Map.Entry<String, CredentialConfiguration> entry : configurations.entrySet()) {
+ final CredentialIssuanceConfiguration settings = entry.getValue().getIssuanceConfiguration();
+ final Instant expiration = settings == null ? null : settings.getFixedExpiration();
+ if (expiration == null) {
+ continue;
+ }
+ if (!expiration.isAfter(now)) {
+ log.warn(
+ "Credential configuration '{}' has a fixed validity window that closed at {}. Nothing is"
+ + " issued from it until its 'not_before' or its 'lifetime' is moved.",
+ entry.getKey(), expiration);
+ } else if (expiration.isBefore(now.plus(CLOSING_WINDOW_WARNING))) {
+ log.warn("Credential configuration '{}' has a fixed validity window that closes at {}.", entry.getKey(),
+ expiration);
+ }
+ }
}
}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
new file mode 100644
index 0000000..03bd2d2
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
@@ -0,0 +1,185 @@
+/*
+ * 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.credential;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Settings of this deployment for one Credential, the
+ * '{@value CredentialConfiguration#ISSUANCE_CONFIGURATION}' member of a
+ * {@link CredentialConfiguration}.
+ *
+ * The member is not part of the specification. It carries what a deployer
+ * decides about a credential rather than what a wallet needs to know about it,
+ * so it is removed from the document that
+ * {@link CredentialConfigurations#getSupported()} publishes.
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+public final class CredentialIssuanceConfiguration {
+
+ /** Length of the validity window, measured from where it starts. */
+ @Nullable
+ private final Duration lifetime;
+
+ /** Start of the validity window. */
+ @Nullable
+ private final ValidityStart notBefore;
+
+ /** Whether credentials of this configuration take a Status List slot. */
+ @Nullable
+ private final Boolean statusList;
+
+ /**
+ * Constructor.
+ *
+ * @param credentialLifetime length of the validity window, as an ISO-8601
+ * duration
+ * @param validityStart start of the validity window, as a duration, a date
+ * or an instant
+ * @param inStatusList whether credentials of this configuration take a
+ * Status List slot
+ */
+ @JsonCreator
+ private CredentialIssuanceConfiguration(@JsonProperty("lifetime") @Nullable final String credentialLifetime,
+ @JsonProperty("not_before") @Nullable final String validityStart,
+ @JsonProperty("status_list") @Nullable final Boolean inStatusList) {
+ lifetime = parseLifetime(credentialLifetime);
+ notBefore = validityStart == null || validityStart.trim().isEmpty() ? null : ValidityStart.parse(validityStart);
+ statusList = inStatusList;
+ }
+
+ /**
+ * Get the length of the validity window.
+ *
+ * @return the lifetime, or null to leave it to the profile configuration
+ */
+ @Nullable
+ public Duration getLifetime() {
+ return lifetime;
+ }
+
+ /**
+ * Get the start of the validity window.
+ *
+ * @return the start, or null for the instant of issuance
+ */
+ @Nullable
+ public ValidityStart getNotBefore() {
+ return notBefore;
+ }
+
+ /**
+ * Get whether credentials of this configuration take a Status List slot.
+ *
+ * @return the flag, or null to leave it to the deployment default
+ */
+ @Nullable
+ public Boolean getStatusList() {
+ return statusList;
+ }
+
+ /**
+ * Resolve the instant the validity window of a credential opens.
+ *
+ * @param issuedAt instant the credential is issued at
+ * @return instant the validity window opens, the instant of issuance when no
+ * start is configured
+ */
+ @Nonnull
+ public Instant resolveNotBefore(@Nonnull final Instant issuedAt) {
+ return notBefore == null ? issuedAt : notBefore.resolve(issuedAt);
+ }
+
+ /**
+ * Resolve the instant the validity window of a credential closes.
+ *
+ * @param issuedAt instant the credential is issued at
+ * @param defaultLifetime length of the window when this configuration states
+ * none of its own
+ * @return instant the validity window closes
+ */
+ @Nonnull
+ public Instant resolveExpiration(@Nonnull final Instant issuedAt, @Nonnull final Duration defaultLifetime) {
+ return resolveNotBefore(issuedAt).plus(lifetime == null ? defaultLifetime : lifetime);
+ }
+
+ /**
+ * Whether the validity window of this configuration is fixed on the calendar,
+ * so that it does not move with the instant of issuance.
+ *
+ * @return true if both ends of the window are known without an issuance
+ */
+ public boolean isFixedWindow() {
+ return lifetime != null && notBefore != null && notBefore.isFixed();
+ }
+
+ /**
+ * Get the instant the validity window of this configuration closes, for a
+ * window that is fixed on the calendar.
+ *
+ * @return the instant the window closes, or null if the window moves with
+ * issuance
+ */
+ @Nullable
+ public Instant getFixedExpiration() {
+ if (!isFixedWindow()) {
+ return null;
+ }
+ assert notBefore != null && lifetime != null;
+ final Instant start = notBefore.getFixed();
+ assert start != null;
+ return start.plus(lifetime);
+ }
+
+ /**
+ * Parse the length of the validity window.
+ *
+ * @param value value to parse
+ * @return the parsed lifetime, or null if none was written
+ * @throws IllegalArgumentException if the value is not a positive duration
+ */
+ @Nullable
+ private static Duration parseLifetime(@Nullable final String value) {
+
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ final String trimmed = value.trim();
+
+ final Duration parsed;
+ try {
+ parsed = Duration.parse(trimmed);
+ } catch (final DateTimeParseException e) {
+ throw new IllegalArgumentException("lifetime '" + trimmed + "' is not a duration. Months and years are"
+ + " not durations, write them as days, 'P365D'.", e);
+ }
+ if (parsed.isZero() || parsed.isNegative()) {
+ throw new IllegalArgumentException("lifetime '" + trimmed + "' has to be positive");
+ }
+ return parsed;
+ }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStart.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStart.java
new file mode 100644
index 0000000..0c58310
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStart.java
@@ -0,0 +1,236 @@
+/*
+ * 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.credential;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.chrono.IsoChronology;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.time.temporal.ChronoField;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Start of the validity window of an issued Credential, the value of the
+ * 'not_before' member of {@link CredentialIssuanceConfiguration}.
+ *
+ * Written as an offset from the instant of issuance, 'P30D', or as a point on
+ * the calendar, '2026-08-01T06:00Z' or '2026-08-01'. A fixed start does not
+ * move with issuance.
+ */
+public final class ValidityStart {
+
+ /** Length of a date without a time of day. */
+ private static final int DATE_LENGTH = 10;
+
+ /**
+ * Format of an exact moment, to the minute, with the offset it is stated in.
+ *
+ * Seconds are optional, {@link Instant#parse(CharSequence)} would require
+ * them. Resolved strictly, so a day a month does not have is refused.
+ */
+ @Nonnull
+ private static final DateTimeFormatter MOMENT_FORMAT = new DateTimeFormatterBuilder()
+ .append(DateTimeFormatter.ISO_LOCAL_DATE).appendLiteral('T').appendValue(ChronoField.HOUR_OF_DAY, 2)
+ .appendLiteral(':').appendValue(ChronoField.MINUTE_OF_HOUR, 2).optionalStart().appendLiteral(':')
+ .appendValue(ChronoField.SECOND_OF_MINUTE, 2).appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+ .optionalEnd().appendOffsetId().toFormatter().withResolverStyle(ResolverStyle.STRICT)
+ .withChronology(IsoChronology.INSTANCE);
+
+ /** Offset from the instant of issuance, when written as a duration. */
+ @Nullable
+ private final Duration offset;
+
+ /** Point on the calendar, when written as a date or as an instant. */
+ @Nullable
+ private final Instant fixed;
+
+ /** The value as it was written. */
+ @Nonnull
+ private final String source;
+
+ /**
+ * Constructor.
+ *
+ * @param duration offset from the instant of issuance, or null
+ * @param instant point on the calendar, or null
+ * @param value the value as it was written
+ */
+ private ValidityStart(@Nullable final Duration duration, @Nullable final Instant instant,
+ @Nonnull final String value) {
+ offset = duration;
+ fixed = instant;
+ source = value;
+ }
+
+ /**
+ * Get the offset from the instant of issuance.
+ *
+ * @return the offset, or null when this start is a point on the calendar
+ */
+ @Nullable
+ public Duration getOffset() {
+ return offset;
+ }
+
+ /**
+ * Get the point on the calendar this start is fixed to.
+ *
+ * @return the instant, or null when this start is an offset from issuance
+ */
+ @Nullable
+ public Instant getFixed() {
+ return fixed;
+ }
+
+ /**
+ * Whether this start is a point on the calendar rather than an offset from the
+ * instant of issuance.
+ *
+ * @return true if this start does not move with issuance
+ */
+ public boolean isFixed() {
+ return fixed != null;
+ }
+
+ /**
+ * Resolve the instant this validity window opens.
+ *
+ * @param issuedAt instant the credential is issued at
+ * @return instant the validity window opens
+ */
+ @Nonnull
+ public Instant resolve(@Nonnull final Instant issuedAt) {
+ if (fixed != null) {
+ return fixed;
+ }
+ assert offset != null;
+ return issuedAt.plus(offset);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nonnull
+ public String toString() {
+ return source;
+ }
+
+ /**
+ * Parse a validity window start of a duration, a date or an instant.
+ *
+ * @param value value to parse
+ * @return the parsed start
+ * @throws IllegalArgumentException if the value is none of the three forms
+ */
+ @Nonnull
+ public static ValidityStart parse(@Nullable final String value) {
+
+ final String trimmed = value == null ? "" : value.trim();
+ if (trimmed.isEmpty()) {
+ throw new IllegalArgumentException("not_before cannot be empty");
+ }
+ if (isDuration(trimmed)) {
+ return ofDuration(trimmed);
+ }
+ if (isDate(trimmed)) {
+ return ofDate(trimmed);
+ }
+ return ofMoment(trimmed);
+ }
+
+ /**
+ * Whether the value is written as an ISO-8601 duration.
+ *
+ * @param value value to look at
+ * @return true if the value is a duration
+ */
+ private static boolean isDuration(@Nonnull final String value) {
+ return value.startsWith("P") || value.startsWith("-P") || value.startsWith("+P");
+ }
+
+ /**
+ * Whether the value is written as a date without a time of day.
+ *
+ * @param value value to look at
+ * @return true if the value is a date
+ */
+ private static boolean isDate(@Nonnull final String value) {
+ return value.length() == DATE_LENGTH && value.indexOf('T') < 0;
+ }
+
+ /**
+ * Build a start of an offset from the instant of issuance.
+ *
+ * @param value value to parse
+ * @return the parsed start
+ * @throws IllegalArgumentException if the value is not a duration
+ */
+ @Nonnull
+ private static ValidityStart ofDuration(@Nonnull final String value) {
+ try {
+ return new ValidityStart(Duration.parse(value), null, value);
+ } catch (final DateTimeParseException e) {
+ throw new IllegalArgumentException("not_before '" + value + "' is not a duration. Months and years are"
+ + " not durations, write them as days, 'P365D'.", e);
+ }
+ }
+
+ /**
+ * Build a start of a date, taken as midnight UTC of that day.
+ *
+ * @param value value to parse
+ * @return the parsed start
+ * @throws IllegalArgumentException if the value is not a date
+ */
+ @Nonnull
+ private static ValidityStart ofDate(@Nonnull final String value) {
+ try {
+ return new ValidityStart(null, LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(), value);
+ } catch (final DateTimeParseException e) {
+ throw new IllegalArgumentException("not_before '" + value + "' is not a date", e);
+ }
+ }
+
+ /**
+ * Build a start of an exact moment.
+ *
+ * The offset the moment is stated in is the deployer's to choose and 'Z' is
+ * UTC. Whatever it is, what is kept is the instant it resolves to.
+ *
+ * @param value value to parse
+ * @return the parsed start
+ * @throws IllegalArgumentException if the value is not a moment
+ */
+ @Nonnull
+ private static ValidityStart ofMoment(@Nonnull final String value) {
+ try {
+ return new ValidityStart(null, OffsetDateTime.parse(value, MOMENT_FORMAT).toInstant(), value);
+ } catch (final DateTimeParseException e) {
+ throw new IllegalArgumentException("not_before '" + value + "' is not a duration such as 'P30D', a moment"
+ + " such as '2026-08-01T06:00Z' or a date such as '2026-08-01'. A moment states the offset it is"
+ + " in, 'Z' for UTC.", e);
+ }
+ }
+
+}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
index 282e17e..a7cc88b 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationsTest.java
@@ -18,7 +18,9 @@ package org.geant.shibboleth.plugin.openidvci.credential;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.Map;
+import java.util.Set;
import org.springframework.core.io.ClassPathResource;
import org.testng.Assert;
@@ -65,6 +67,44 @@ public class CredentialConfigurationsTest {
.parse("{\"credential_configurations_supported\":{}}".getBytes(StandardCharsets.UTF_8)));
}
+ /**
+ * The settings of this deployment are parsed, and they are dropped from what
+ * gets published.
+ *
+ * @throws IOException if the fixture does not parse
+ */
+ @Test
+ public void parseIssuanceConfiguration() throws IOException {
+
+ final CredentialConfigurations configurations = CredentialConfigurations
+ .parse(("{\"credential_configurations_supported\":{"
+ + "\"Diploma\":{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\","
+ + "\"_issuance_configuration\":{\"lifetime\":\"P365D\",\"not_before\":\"2026-08-01\","
+ + "\"status_list\":false}}," + "\"Card\":{\"format\":\"dc+sd-jwt\",\"vct\":\"Card\"}}}")
+ .getBytes(StandardCharsets.UTF_8));
+
+ // Parsed as the instruction on how the credential is formed.
+ final CredentialIssuanceConfiguration settings = configurations.getCredentialConfigurations().get("Diploma")
+ .getIssuanceConfiguration();
+ Assert.assertEquals(settings.getLifetime(), Duration.ofDays(365));
+ Assert.assertTrue(settings.getNotBefore().isFixed());
+ Assert.assertEquals(settings.getStatusList(), Boolean.FALSE);
+ Assert.assertNull(configurations.getCredentialConfigurations().get("Card").getIssuanceConfiguration());
+
+ // Dropped from what gets published, the rest of the member is untouched.
+ Assert.assertEquals(configurations.getSupported().keySet(), Set.of("Diploma", "Card"));
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> published = (Map<String, Object>) configurations.getSupported().get("Diploma");
+ Assert.assertFalse(published.containsKey(CredentialConfiguration.ISSUANCE_CONFIGURATION));
+ Assert.assertEquals(published.get("format"), "dc+sd-jwt");
+ Assert.assertEquals(published.get("vct"), "Diploma");
+
+ // A configuration without the member is published as it was.
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> card = (Map<String, Object>) configurations.getSupported().get("Card");
+ Assert.assertEquals(card.keySet().size(), 2);
+ }
+
@Test
public void parseUnparsableCredentialConfiguration() {
Assert.assertThrows(IOException.class,
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.java
new file mode 100644
index 0000000..00d0f42
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfigurationTest.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.openidvci.credential;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+/**
+ * Unit tests for {@link CredentialIssuanceConfiguration}, parsed as the private
+ * member of a {@link CredentialConfiguration}.
+ */
+public class CredentialIssuanceConfigurationTest {
+
+ private static final Instant ISSUED_AT = Instant.parse("2026-10-15T09:30:00Z");
+
+ private static final Duration PROFILE_LIFETIME = Duration.ofDays(180);
+
+ private String withSettings(final String settings) {
+ return "{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\",\"_issuance_configuration\":" + settings + "}";
+ }
+
+ @Test
+ public void absent() throws JsonProcessingException {
+
+ final CredentialConfiguration configuration = CredentialConfiguration
+ .parse("{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\"}");
+ Assert.assertNull(configuration.getIssuanceConfiguration());
+ }
+
+ @Test
+ public void offsetWindow() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"P365D\",\"not_before\":\"P30D\"}")).getIssuanceConfiguration();
+
+ Assert.assertEquals(settings.getLifetime(), Duration.ofDays(365));
+ Assert.assertFalse(settings.isFixedWindow());
+ Assert.assertNull(settings.getFixedExpiration());
+
+ Assert.assertEquals(settings.resolveNotBefore(ISSUED_AT), Instant.parse("2026-11-14T09:30:00Z"));
+ Assert.assertEquals(settings.resolveExpiration(ISSUED_AT, PROFILE_LIFETIME),
+ Instant.parse("2027-11-14T09:30:00Z"));
+ }
+
+ @Test
+ public void fixedWindowOfASchoolYear() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"P365D\",\"not_before\":\"2026-08-01\"}"))
+ .getIssuanceConfiguration();
+
+ Assert.assertTrue(settings.isFixedWindow());
+ Assert.assertEquals(settings.getFixedExpiration(), Instant.parse("2027-08-01T00:00:00Z"));
+
+ Assert.assertEquals(settings.resolveNotBefore(ISSUED_AT), Instant.parse("2026-08-01T00:00:00Z"));
+ Assert.assertEquals(settings.resolveExpiration(ISSUED_AT, PROFILE_LIFETIME),
+ Instant.parse("2027-08-01T00:00:00Z"));
+
+ Assert.assertEquals(settings.resolveExpiration(Instant.parse("2026-08-02T12:00:00Z"), PROFILE_LIFETIME),
+ settings.resolveExpiration(Instant.parse("2027-01-07T08:00:00Z"), PROFILE_LIFETIME));
+ }
+
+ @Test
+ public void lifetimeOnly() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"P7D\"}")).getIssuanceConfiguration();
+
+ Assert.assertEquals(settings.getLifetime(), Duration.ofDays(7));
+ Assert.assertNull(settings.getNotBefore());
+ Assert.assertFalse(settings.isFixedWindow());
+
+ Assert.assertEquals(settings.resolveNotBefore(ISSUED_AT), ISSUED_AT);
+ Assert.assertEquals(settings.resolveExpiration(ISSUED_AT, PROFILE_LIFETIME),
+ Instant.parse("2026-10-22T09:30:00Z"));
+ }
+
+ @Test
+ public void notBeforeOnlyFallsBackToTheProfileLifetime() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"not_before\":\"2026-08-01\"}")).getIssuanceConfiguration();
+
+ Assert.assertNull(settings.getLifetime());
+ Assert.assertFalse(settings.isFixedWindow());
+ Assert.assertEquals(settings.resolveExpiration(ISSUED_AT, PROFILE_LIFETIME),
+ Instant.parse("2026-08-01T00:00:00Z").plus(PROFILE_LIFETIME));
+ }
+
+ @Test
+ public void statusList() throws JsonProcessingException {
+
+ Assert.assertNull(CredentialConfiguration.parse(withSettings("{\"lifetime\":\"P7D\"}"))
+ .getIssuanceConfiguration().getStatusList());
+ Assert.assertEquals(CredentialConfiguration.parse(withSettings("{\"status_list\":true}"))
+ .getIssuanceConfiguration().getStatusList(), Boolean.TRUE);
+ Assert.assertEquals(CredentialConfiguration.parse(withSettings("{\"status_list\":false}"))
+ .getIssuanceConfiguration().getStatusList(), Boolean.FALSE);
+ }
+
+ @Test
+ public void emptyValuesAreAbsentValues() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"\",\"not_before\":\" \"}")).getIssuanceConfiguration();
+
+ Assert.assertNull(settings.getLifetime());
+ Assert.assertNull(settings.getNotBefore());
+ }
+
+ @Test
+ public void unknownMembersAreIgnored() throws JsonProcessingException {
+
+ final CredentialIssuanceConfiguration settings = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"P7D\",\"something_later\":\"value\"}")).getIssuanceConfiguration();
+
+ Assert.assertEquals(settings.getLifetime(), Duration.ofDays(7));
+ }
+
+ @Test
+ public void unparsableValuesAreRefused() {
+
+ Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"lifetime\":\"P1Y\"}")));
+ Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"lifetime\":\"one year\"}")));
+ Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"not_before\":\"1.8.2026\"}")));
+ Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"lifetime\":\"PT0S\"}")));
+ Assert.assertThrows(() -> CredentialConfiguration.parse(withSettings("{\"lifetime\":\"-P7D\"}")));
+ }
+
+ @Test
+ public void memberIsNotSerialized() throws JsonProcessingException {
+
+ final CredentialConfiguration configuration = CredentialConfiguration
+ .parse(withSettings("{\"lifetime\":\"P365D\",\"status_list\":true}"));
+ Assert.assertNotNull(configuration.getIssuanceConfiguration());
+
+ final String serialized = configuration.serialize();
+ Assert.assertFalse(serialized.contains(CredentialConfiguration.ISSUANCE_CONFIGURATION));
+ Assert.assertFalse(serialized.contains("lifetime"));
+ Assert.assertTrue(serialized.contains("dc+sd-jwt"));
+ }
+
+}
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStartTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStartTest.java
new file mode 100644
index 0000000..69e3981
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/ValidityStartTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.credential;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link ValidityStart}.
+ */
+public class ValidityStartTest {
+
+ private static final Instant ISSUED_AT = Instant.parse("2026-10-15T09:30:00Z");
+
+ @Test
+ public void parseOffset() {
+
+ final ValidityStart start = ValidityStart.parse("P30D");
+ Assert.assertFalse(start.isFixed());
+ Assert.assertEquals(start.getOffset(), Duration.ofDays(30));
+ Assert.assertNull(start.getFixed());
+ Assert.assertEquals(start.resolve(ISSUED_AT), Instant.parse("2026-11-14T09:30:00Z"));
+ Assert.assertEquals(start.toString(), "P30D");
+ }
+
+ @Test
+ public void parseZeroOffset() {
+
+ final ValidityStart start = ValidityStart.parse("PT0S");
+ Assert.assertFalse(start.isFixed());
+ Assert.assertEquals(start.resolve(ISSUED_AT), ISSUED_AT);
+ }
+
+ @Test
+ public void parseNegativeOffset() {
+
+ final ValidityStart start = ValidityStart.parse("-P7D");
+ Assert.assertFalse(start.isFixed());
+ Assert.assertEquals(start.resolve(ISSUED_AT), Instant.parse("2026-10-08T09:30:00Z"));
+ }
+
+ @Test
+ public void parseDate() {
+
+ final ValidityStart start = ValidityStart.parse("2026-08-01");
+ Assert.assertTrue(start.isFixed());
+ Assert.assertNull(start.getOffset());
+ Assert.assertEquals(start.getFixed(), Instant.parse("2026-08-01T00:00:00Z"));
+ Assert.assertEquals(start.resolve(ISSUED_AT), Instant.parse("2026-08-01T00:00:00Z"));
+ Assert.assertEquals(start.toString(), "2026-08-01");
+ }
+
+ @Test
+ public void parseMomentToTheMinute() {
+
+ final ValidityStart start = ValidityStart.parse("2026-08-01T06:00Z");
+ Assert.assertTrue(start.isFixed());
+ Assert.assertNull(start.getOffset());
+ Assert.assertEquals(start.getFixed(), Instant.parse("2026-08-01T06:00:00Z"));
+ Assert.assertEquals(start.resolve(ISSUED_AT), Instant.parse("2026-08-01T06:00:00Z"));
+ Assert.assertEquals(start.toString(), "2026-08-01T06:00Z");
+ }
+
+ @Test
+ public void parseMomentWithSeconds() {
+
+ Assert.assertEquals(ValidityStart.parse("2026-08-01T00:00:00Z").getFixed(),
+ Instant.parse("2026-08-01T00:00:00Z"));
+ Assert.assertEquals(ValidityStart.parse("2026-08-01T06:00:00.500Z").getFixed(),
+ Instant.parse("2026-08-01T06:00:00.500Z"));
+ }
+
+ @Test
+ public void parseMomentWithOffset() {
+
+ Assert.assertEquals(ValidityStart.parse("2026-08-01T09:00+03:00").getFixed(),
+ Instant.parse("2026-08-01T06:00:00Z"));
+ Assert.assertEquals(ValidityStart.parse("2026-08-01T00:00:00+03:00").getFixed(),
+ Instant.parse("2026-07-31T21:00:00Z"));
+ }
+
+ @Test
+ public void parseTrimsSurroundingSpace() {
+
+ Assert.assertEquals(ValidityStart.parse(" 2026-08-01 ").getFixed(), Instant.parse("2026-08-01T00:00:00Z"));
+ Assert.assertEquals(ValidityStart.parse(" P30D ").getOffset(), Duration.ofDays(30));
+ }
+
+ @Test
+ public void parseEmpty() {
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse(""));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse(" "));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse(null));
+ }
+
+ @Test
+ public void parseMonthsAndYearsAreNotDurations() {
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("P1Y"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("P6M"));
+ }
+
+ @Test
+ public void parseUnparsable() {
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("start of school year"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-13-01"));
+ // A day the month does not have is refused, not moved to one it has.
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-02-30"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-02-30T06:00Z"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("1.8.2026"));
+ // A moment states the offset it is in, there is nothing to assume for it.
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-08-01T06:00"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-08-01T00:00:00"));
+ // And it states the time of day at least to the minute.
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-08-01T06Z"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> ValidityStart.parse("2026-08-01 06:00Z"));
+ }
+
+}
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 904201b..ae84095 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
@@ -54,6 +54,10 @@ public class CredentialsContext extends BaseContext {
@Nullable
private Instant issuedAt;
+ /** Instant the validity window of all credentials of this request opens. */
+ @Nullable
+ private Instant notBefore;
+
/** Expiration instant shared by all credentials of this request. */
@Nullable
private Instant expiresAt;
@@ -113,6 +117,29 @@ public class CredentialsContext extends BaseContext {
this.issuedAt = issuanceInstant;
}
+ /**
+ * Get instant the validity window of all credentials of this request opens.
+ *
+ * This is the instant of issuance unless the credential configuration states a
+ * 'not_before' of its own.
+ *
+ * @return Instant the validity window opens
+ */
+ @Nullable
+ public Instant getNotBefore() {
+ return notBefore;
+ }
+
+ /**
+ * Set instant the validity window of all credentials of this request opens.
+ *
+ * @param validityStart Instant the validity window of all credentials of this
+ * request opens
+ */
+ public void setNotBefore(@Nullable final Instant validityStart) {
+ this.notBefore = validityStart;
+ }
+
/**
* Get expiration instant shared by all credentials of this request.
*
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 0cc33a2..acb9c93 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
@@ -89,6 +89,10 @@ public class AddCredentialShell extends AbstractProfileAction {
@Nullable
private Instant issuedAt;
+ /** Instant the validity window of all credentials of this request opens. */
+ @Nullable
+ private Instant notBefore;
+
/** Expiration instant shared by all credentials of this request. */
@Nullable
private Instant expiresAt;
@@ -168,16 +172,19 @@ public class AddCredentialShell extends AbstractProfileAction {
return false;
}
- ctx = profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
+ ctx = profileRequestContext.getInboundMessageContext() == null ? null
+ : profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
if (ctx == null) {
+ // TODO: A missing credentials context is not a relying party problem.
log.debug("{} No credentials context associated with this profile request", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
return false;
}
issuedAt = ctx.getIssuedAt();
+ notBefore = ctx.getNotBefore();
expiresAt = ctx.getExpiresAt();
- if (issuedAt == null || expiresAt == null) {
+ if (issuedAt == null || notBefore == 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;
@@ -258,12 +265,15 @@ public class AddCredentialShell extends AbstractProfileAction {
shell.setIssuer(new Issuer(issuerId));
shell.setClaim("iat", issuedAt.getEpochSecond());
+ if (!notBefore.equals(issuedAt)) {
+ shell.setClaim("nbf", notBefore.getEpochSecond());
+ }
shell.setClaim("exp", expiresAt.getEpochSecond());
if ("dc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
shell.setClaim("vct", ctx.getCredentialConfiguration().getVct());
}
if ("vc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
- shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(issuedAt));
+ shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(notBefore));
shell.setClaim("validUntil", DateTimeFormatter.ISO_INSTANT.format(expiresAt));
}
if (statusListContext != null && statusListContext.getStatusListUri() != null) {
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
index b6cf2fa..b6671da 100644
--- 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
@@ -30,6 +30,8 @@ import net.shibboleth.profile.context.RelyingPartyContext;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
import org.geant.shibboleth.plugin.openidvci.profile.config.OpenIDVCIConfiguration;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
import org.opensaml.messaging.context.navigate.ChildContextLookup;
@@ -39,15 +41,15 @@ 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()}.
+ * 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.
+ * 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 {
@@ -66,6 +68,10 @@ public class ResolveCredentialLifetime extends AbstractProfileAction {
@Nullable
private Duration lifetime;
+ /** Settings the Credential Configuration in issue states, if any. */
+ @Nullable
+ private CredentialIssuanceConfiguration settings;
+
/** Credentials context. */
@Nullable
private CredentialsContext ctx;
@@ -125,6 +131,14 @@ public class ResolveCredentialLifetime extends AbstractProfileAction {
}
ctx = profileRequestContext.getInboundMessageContext().ensureSubcontext(CredentialsContext.class);
+ final CredentialConfiguration configuration = ctx.getCredentialConfiguration();
+ settings = configuration == null ? null : configuration.getIssuanceConfiguration();
+ if (settings != null && settings.getLifetime() != null) {
+ log.debug("{} Credential configuration states a lifetime of {}, in place of the profile"
+ + " configuration's {}", getLogPrefix(), settings.getLifetime(), lifetime);
+ lifetime = settings.getLifetime();
+ }
+
return true;
}
@@ -135,10 +149,29 @@ public class ResolveCredentialLifetime extends AbstractProfileAction {
assert ctx != null && lifetime != null;
final Instant issuedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS);
+ final Instant notBefore = (settings == null ? issuedAt : settings.resolveNotBefore(issuedAt))
+ .truncatedTo(ChronoUnit.SECONDS);
+ final Instant expiresAt = notBefore.plus(lifetime);
+
+ if (!expiresAt.isAfter(issuedAt)) {
+ // TODO: A closed window deserves an error the wallet is able to act on.
+ log.error(
+ "{} Validity window of the requested credential closed at {}, there is nothing to issue."
+ + " Move the 'not_before' or the 'lifetime' of its credential configuration.",
+ getLogPrefix(), expiresAt);
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return;
+ }
+
ctx.setIssuedAt(issuedAt);
- ctx.setExpiresAt(issuedAt.plus(lifetime));
+ ctx.setNotBefore(notBefore);
+ ctx.setExpiresAt(expiresAt);
- log.debug("{} Credentials of this request are valid from {} until {}", getLogPrefix(), ctx.getIssuedAt(),
+ if (notBefore.isAfter(issuedAt)) {
+ log.debug("{} Credentials of this request are issued at {} and are not valid before {}", getLogPrefix(),
+ issuedAt, notBefore);
+ }
+ log.debug("{} Credentials of this request are valid from {} until {}", getLogPrefix(), ctx.getNotBefore(),
ctx.getExpiresAt());
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicate.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicate.java
new file mode 100644
index 0000000..1feb84e
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicate.java
@@ -0,0 +1,71 @@
+/*
+ * 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.function.Predicate;
+
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+/**
+ * Whether the credentials of this request take a Status List slot, as the
+ * 'status_list' member of the Credential Configuration in issue states it.
+ *
+ * Conditions the actions that allocate and record a slot. Set the default to
+ * false to make the Status List something each Credential Configuration opts
+ * into.
+ */
+public class StatusListInclusionPredicate implements Predicate<ProfileRequestContext> {
+
+ /**
+ * Whether a Credential Configuration that states nothing takes a slot.
+ */
+ private boolean defaultInclusion = true;
+
+ /**
+ * Set whether a Credential Configuration that states nothing takes a slot.
+ *
+ * @param flag whether a Credential Configuration that states nothing takes a
+ * slot
+ */
+ public void setDefaultInclusion(final boolean flag) {
+ defaultInclusion = flag;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean test(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+ return defaultInclusion;
+ }
+
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ final CredentialConfiguration configuration = ctx == null ? null : ctx.getCredentialConfiguration();
+ final CredentialIssuanceConfiguration settings = configuration == null ? null
+ : configuration.getIssuanceConfiguration();
+ final Boolean statusList = settings == null ? null : settings.getStatusList();
+
+ return statusList == null ? defaultInclusion : statusList.booleanValue();
+ }
+
+}
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 ac2b7b7..e674bc0 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
@@ -18,10 +18,15 @@
p:storage-ref="#{'%{openidvci.status-list.index.StorageService:shibboleth.StorageService}'.trim()}"
p:permutationKey="%{openidvci.status-list.index.permutationKey:}" />
+ <bean id="openidvci.status-list.InclusionCondition"
+ class="org.geant.shibboleth.plugin.openidvci.profile.logic.StatusListInclusionPredicate"
+ p:defaultInclusion="%{openidvci.status-list.includeByDefault:true}" />
+
<bean id="AllocateIndex"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.AllocateIndex"
scope="prototype"
p:statusListAllocator-ref="openidvci.status-list.IndexAllocator"
+ p:activationCondition-ref="openidvci.status-list.InclusionCondition"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier">
<property name="expirationLookupStrategy">
<bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
@@ -41,7 +46,8 @@
<bean id="AssignIndex"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.AssignIndex"
scope="prototype"
- p:statusListAssignmentStore-ref="openidvci.status-list.AssignmentStore">
+ p:statusListAssignmentStore-ref="openidvci.status-list.AssignmentStore"
+ p:activationCondition-ref="openidvci.status-list.InclusionCondition">
<property name="expirationLookupStrategy">
<bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
</property>
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 97c9275..5bb09eb 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
@@ -17,6 +17,9 @@ openidvci.issuer =
# The location for the JSON file of Credential Configurations, published in Credential Issuer
# metadata and used as the instruction on how a credential is formed. Every flow reads them
# from here, through one resolver, so the file is read when it changes and not once per request.
+# Settings that are this deployment's own, the validity window of a credential and whether it
+# takes a Status List slot, are stated per credential in that same file, in an
+# '_issuance_configuration' member that is parsed and then dropped from what gets published.
#openidvci.credentialConfigurations.resource = %{idp.home}/metadata/verifiable-credentials.json
# Bounds on the next file refresh of the Credential Configurations resource
#openidvci.credentialConfigurations.minRefreshDelay = PT5M
@@ -54,6 +57,10 @@ openidvci.signing.issuerMetadata.es.key = %{idp.home}/credentials/openid-vci-iss
# Do *NOT* share the key with other people, it's like divulging your private key.
# It is suggested you move this property into credentials/secrets.properties
openidvci.status-list.index.permutationKey = change_this_to_secret
+# Whether a credential whose configuration states no 'status_list' takes a slot of the
+# Status List. Set to false to make the Status List something each Credential
+# Configuration opts into with '"status_list": true' instead.
+#openidvci.status-list.includeByDefault = true
# Default lifetime of Status List Tokens
#openidvci.status-list.token.defaultLifetime = PT1H
# Length of time an assignment record is kept for collection after it was written
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
index c11b16c..4dd6802 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
@@ -16,10 +16,13 @@
package org.geant.shibboleth.plugin.openidvci.credential.impl;
+import java.time.Duration;
import java.util.Map;
import java.util.Set;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
import org.springframework.core.io.ClassPathResource;
import org.testng.Assert;
import org.testng.annotations.Test;
@@ -68,6 +71,32 @@ public class FilesystemCredentialConfigurationsResolverTest {
Assert.assertTrue(source.containsKey("credential_metadata"));
}
+ @Test
+ public void testSettingsOfTheDeploymentAreParsed() throws Exception {
+ final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ Assert.assertNotNull(configurations);
+ final CredentialIssuanceConfiguration settings = configurations.getCredentialConfigurations()
+ .get("GeantIncubatorDiploma").getIssuanceConfiguration();
+ Assert.assertEquals(settings.getLifetime(), Duration.ofDays(365));
+ Assert.assertEquals(settings.getNotBefore().getOffset(), Duration.ofDays(30));
+ Assert.assertEquals(settings.getStatusList(), Boolean.TRUE);
+ Assert.assertEquals(configurations.getCredentialConfigurations().get("GeantIncubatorDiploma2")
+ .getIssuanceConfiguration().getStatusList(), Boolean.FALSE);
+ }
+
+ @Test
+ public void testSettingsOfTheDeploymentAreNotPublished() throws Exception {
+ final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ Assert.assertNotNull(configurations);
+ for (final Object configuration : configurations.getSupported().values()) {
+ Assert.assertFalse(((Map<?, ?>) configuration).containsKey(CredentialConfiguration.ISSUANCE_CONFIGURATION));
+ }
+ }
+
@Test
public void testTheSameInstanceIsServedUntilTheFileChanges() throws Exception {
final FilesystemCredentialConfigurationsResolver resolver = resolverOf("conf/verifiable-credentials.json");
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
index 39adbb9..6c1c632 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
@@ -69,6 +70,19 @@ public class CredentialConfigurationsValueResolverTest {
Assert.assertTrue(configuration.containsKey("credential_metadata"));
}
+ @Test
+ public void testSettingsOfTheDeploymentAreNotPublished() throws Exception {
+ @SuppressWarnings("unchecked")
+ final Map<String, Object> resolved = (Map<String, Object>) adapterOf("conf/verifiable-credentials.json")
+ .resolveSingle(null);
+
+ for (final Object configuration : resolved.values()) {
+ Assert.assertFalse(((Map<?, ?>) configuration).containsKey(CredentialConfiguration.ISSUANCE_CONFIGURATION));
+ }
+ Assert.assertFalse(resolved.toString().contains("lifetime"));
+ Assert.assertFalse(resolved.toString().contains("status_list"));
+ }
+
@Test
public void testRefreshIsDelegated() throws Exception {
final CredentialConfigurationsValueResolver adapter = adapterOf("conf/verifiable-credentials.json");
@@ -88,7 +102,6 @@ public class CredentialConfigurationsValueResolverTest {
Assert.assertNull(adapter.resolveSingle(null));
Assert.assertFalse(adapter.resolve(null).iterator().hasNext());
- // Nothing to refresh, and no failure for it either.
adapter.refresh();
Assert.assertNull(adapter.getLastRefresh());
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetimeTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetimeTest.java
new file mode 100644
index 0000000..2c90557
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetimeTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCICredentialsConfiguration;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.profile.context.RelyingPartyContext;
+
+/**
+ * Unit tests for {@link ResolveCredentialLifetime}.
+ */
+public class ResolveCredentialLifetimeTest {
+
+ private static final Duration PROFILE_LIFETIME = Duration.ofDays(180);
+
+ private ProfileRequestContext profileRequestCtx;
+
+ private RequestContext requestCtx;
+
+ private ResolveCredentialLifetime action;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ requestCtx = new RequestContextBuilder().buildRequestContext();
+ profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+ profileRequestCtx.ensureSubcontext(RelyingPartyContext.class)
+ .setProfileConfig(new DefaultOpenIDVCICredentialsConfiguration());
+ profileRequestCtx.setInboundMessageContext(new MessageContext());
+
+ action = new ResolveCredentialLifetime();
+ action.initialize();
+ }
+
+ private void credentialWith(final String settings) throws Exception {
+ final String configuration = "{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\""
+ + (settings == null ? "" : ",\"_issuance_configuration\":" + settings) + "}";
+ profileRequestCtx.ensureInboundMessageContext().ensureSubcontext(CredentialsContext.class)
+ .setCredentialConfiguration(CredentialConfiguration.parse(configuration));
+ }
+
+ private CredentialsContext resolved() {
+ return profileRequestCtx.ensureInboundMessageContext().ensureSubcontext(CredentialsContext.class);
+ }
+
+ @Test
+ public void testWindowOpensAtIssuance() throws Exception {
+ credentialWith(null);
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final CredentialsContext ctx = resolved();
+ Assert.assertNotNull(ctx.getIssuedAt());
+ // Nothing states a start, so there is no window to announce beyond 'iat'.
+ Assert.assertEquals(ctx.getNotBefore(), ctx.getIssuedAt());
+ Assert.assertEquals(ctx.getExpiresAt(), ctx.getIssuedAt().plus(PROFILE_LIFETIME));
+ }
+
+ @Test
+ public void testCredentialLifetimeOverridesTheProfileConfiguration() throws Exception {
+ credentialWith("{\"lifetime\":\"P7D\"}");
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final CredentialsContext ctx = resolved();
+ Assert.assertEquals(ctx.getNotBefore(), ctx.getIssuedAt());
+ Assert.assertEquals(ctx.getExpiresAt(), ctx.getIssuedAt().plus(Duration.ofDays(7)));
+ }
+
+ @Test
+ public void testOffsetStartDefersTheWindow() throws Exception {
+ credentialWith("{\"lifetime\":\"P365D\",\"not_before\":\"P30D\"}");
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final CredentialsContext ctx = resolved();
+ Assert.assertEquals(ctx.getNotBefore(), ctx.getIssuedAt().plus(Duration.ofDays(30)));
+ Assert.assertEquals(ctx.getExpiresAt(), ctx.getNotBefore().plus(Duration.ofDays(365)));
+ }
+
+ @Test
+ public void testFixedStartDoesNotMoveWithIssuance() throws Exception {
+ credentialWith("{\"lifetime\":\"P36500D\",\"not_before\":\"2020-01-01\"}");
+
+ ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+ final CredentialsContext ctx = resolved();
+ Assert.assertEquals(ctx.getNotBefore(), Instant.parse("2020-01-01T00:00:00Z"));
+ Assert.assertEquals(ctx.getExpiresAt(), Instant.parse("2020-01-01T00:00:00Z").plus(Duration.ofDays(36500)));
+ Assert.assertTrue(ctx.getIssuedAt().isAfter(ctx.getNotBefore()));
+ }
+
+ @Test
+ public void testFixedStartWithoutALifetimeTakesTheProfileOne() throws Exception {
+ credentialWith("{\"not_before\":\"2020-01-01\"}");
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+ Assert.assertNull(resolved().getExpiresAt());
+ }
+
+ @Test
+ public void testClosedWindowIssuesNothing() throws Exception {
+ credentialWith("{\"lifetime\":\"P1D\",\"not_before\":\"2020-01-01\"}");
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+ final CredentialsContext ctx = resolved();
+ Assert.assertNull(ctx.getIssuedAt());
+ Assert.assertNull(ctx.getNotBefore());
+ Assert.assertNull(ctx.getExpiresAt());
+ }
+
+ @Test
+ public void testNoRelyingPartyContext() throws Exception {
+ credentialWith(null);
+ profileRequestCtx.removeSubcontext(RelyingPartyContext.class);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_RELYING_PARTY_CTX);
+ }
+
+ @Test
+ public void testNoInboundMessageContext() throws Exception {
+ credentialWith(null);
+ profileRequestCtx.setInboundMessageContext(null);
+ ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+ }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicateTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicateTest.java
new file mode 100644
index 0000000..e871bc6
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListInclusionPredicateTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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 org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+/**
+ * Tests for {@link StatusListInclusionPredicate}.
+ */
+public class StatusListInclusionPredicateTest {
+
+ private ProfileRequestContext requestFor(final String settings) throws JsonProcessingException {
+
+ final String configuration = "{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\""
+ + (settings == null ? "" : ",\"_issuance_configuration\":" + settings) + "}";
+
+ final ProfileRequestContext prc = new ProfileRequestContext();
+ prc.setInboundMessageContext(new MessageContext());
+ prc.ensureInboundMessageContext().ensureSubcontext(CredentialsContext.class)
+ .setCredentialConfiguration(CredentialConfiguration.parse(configuration));
+ return prc;
+ }
+
+ @Test
+ public void statedTrue() throws JsonProcessingException {
+ Assert.assertTrue(new StatusListInclusionPredicate().test(requestFor("{\"status_list\":true}")));
+ }
+
+ @Test
+ public void statedFalse() throws JsonProcessingException {
+ Assert.assertFalse(new StatusListInclusionPredicate().test(requestFor("{\"status_list\":false}")));
+ }
+
+ @Test
+ public void statedFalseOverridesTheDefault() throws JsonProcessingException {
+ final StatusListInclusionPredicate condition = new StatusListInclusionPredicate();
+ condition.setDefaultInclusion(true);
+ Assert.assertFalse(condition.test(requestFor("{\"status_list\":false}")));
+ }
+
+ @Test
+ public void notStated() throws JsonProcessingException {
+ Assert.assertTrue(new StatusListInclusionPredicate().test(requestFor("{\"lifetime\":\"P365D\"}")));
+ Assert.assertTrue(new StatusListInclusionPredicate().test(requestFor(null)));
+ }
+
+ @Test
+ public void notStatedWithTheDefaultTurnedOff() throws JsonProcessingException {
+ final StatusListInclusionPredicate condition = new StatusListInclusionPredicate();
+ condition.setDefaultInclusion(false);
+ Assert.assertFalse(condition.test(requestFor("{\"lifetime\":\"P365D\"}")));
+ Assert.assertFalse(condition.test(requestFor(null)));
+ // What a configuration opts into is still honoured.
+ Assert.assertTrue(condition.test(requestFor("{\"status_list\":true}")));
+ }
+
+ @Test
+ public void noContexts() {
+ final StatusListInclusionPredicate condition = new StatusListInclusionPredicate();
+ Assert.assertTrue(condition.test(null));
+ Assert.assertTrue(condition.test(new ProfileRequestContext()));
+
+ condition.setDefaultInclusion(false);
+ Assert.assertFalse(condition.test(null));
+ }
+
+}
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 65a70c9..d54922c 100644
--- a/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
+++ b/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
@@ -13,6 +13,11 @@
}
},
"vct": "GeantIncubatorDiploma",
+ "_issuance_configuration": {
+ "lifetime": "P365D",
+ "not_before": "P30D",
+ "status_list": true
+ },
"credential_metadata": {
"display": [
{
@@ -73,6 +78,9 @@
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "GeantIncubatorDiploma2"]
},
+ "_issuance_configuration": {
+ "status_list": false
+ },
"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