[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