[java-idp-plugin-vci] branch main updated: Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.

Codeberg noreply at shibboleth.net
Wed Sep 2 12:51:17 UTC 2026


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

codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.

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

The following commit(s) were added to refs/heads/main by this push:
     new 01867fa  Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.
01867fa is described below

commit 01867fa9d9614194121a474f92e6e044aaa0f029
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 2 15:51:00 2026 +0300

    Issuer configuration published by specific flow instead of only static files. Credential configuration has now resolver instead of reading it only from a file. Initial decorator for publishing statements in openid federation configuration.
---
 openid-vci-api/pom.xml                             |   5 +
 .../credential/CredentialConfigurations.java       |  92 +++++--
 .../resolver/CredentialConfigurationsResolver.java |  31 +++
 ...efreshableCredentialConfigurationsResolver.java |  57 ++++
 .../metadata/CredentialIssuerMetadata.java         | 135 +++++++++
 .../resolver/CredentialIssuerMetadataResolver.java |  31 +++
 .../CredentialIssuerMetadataValueResolver.java     |  30 ++
 ...hableCredentialIssuerMetadataValueResolver.java |  57 ++++
 .../credential/CredentialConfigurationsTest.java   |  37 ++-
 .../metadata/CredentialIssuerMetadataTest.java     | 115 ++++++++
 openid-vci-impl/pom.xml                            |  20 ++
 ...efaultOpenIDVCIIssuerMetadataConfiguration.java |  49 ++++
 ...FilesystemCredentialConfigurationsResolver.java | 110 ++++++++
 .../CredentialIssuerMetadataSuccessResponse.java   |  67 +++++
 .../CredentialConfigurationsValueResolver.java     | 120 ++++++++
 ...FilesystemCredentialIssuerMetadataResolver.java | 131 +++++++++
 ...FilesystemCredentialIssuerMetadataResolver.java | 300 ++++++++++++++++++++
 ...IssuerEntityConfigurationMetadataDecorator.java | 306 +++++++++++++++++++++
 .../impl/AbstractCredentialValidationAction.java   |  57 ++--
 .../FormOutboundIssuerMetadataResponseMessage.java | 140 ++++++++++
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  68 +++++
 .../credential-offer/credential-offer-beans.xml    |   2 +-
 .../openid/vci/credentials/credentials-beans.xml   |   2 +-
 .../vci/issuer-metadata/issuer-metadata-beans.xml  |  39 +++
 .../vci/issuer-metadata/issuer-metadata-flow.xml   |  27 ++
 .../idp/service/relying-party/postconfig.xml       |   8 +
 .../openidvci/conf/openid-vci-credentials.xml      | 112 +++++---
 .../plugin/openidvci/conf/openid-vci-oidfed.xml    | 101 +++++++
 .../plugin/openidvci/conf/openid-vci.properties    |  35 ++-
 .../shibboleth/plugin/openidvci/module.properties  |  18 +-
 .../openidvci/static/openid-credential-issuer.json |  14 +
 ...systemCredentialConfigurationsResolverTest.java |  99 +++++++
 .../CredentialConfigurationsValueResolverTest.java | 119 ++++++++
 ...systemCredentialIssuerMetadataResolverTest.java | 246 +++++++++++++++++
 ...erEntityConfigurationMetadataDecoratorTest.java | 277 +++++++++++++++++++
 ...mOutboundIssuerMetadataResponseMessageTest.java | 163 +++++++++++
 .../impl/ValidateCredentialOfferingTest.java       |  60 +++-
 .../conf/no-credential-configurations.json         |   3 +
 .../conf/unparsable-credential-configuration.json  |   8 +
 .../src/test/resources/credentials/sealer.jks      | Bin 500 -> 0 bytes
 .../src/test/resources/credentials/sealer.kver     |   1 -
 .../openid-credential-issuer-no-issuer.json        |   3 +
 .../openid-credential-issuer-template.json         |  14 +
 .../openid-credential-issuer-unresolved.json       |   4 +
 .../metadata/openid-credential-issuer.json         |  14 +
 pom.xml                                            |   3 +
 46 files changed, 3232 insertions(+), 98 deletions(-)

diff --git a/openid-vci-api/pom.xml b/openid-vci-api/pom.xml
index 8a63ba0..bb26ddc 100644
--- a/openid-vci-api/pom.xml
+++ b/openid-vci-api/pom.xml
@@ -45,6 +45,11 @@
             <artifactId>jackson-databind</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${opensaml.groupId}</groupId>
+            <artifactId>opensaml-profile-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <!-- test dependencies -->
         <dependency>
             <groupId>org.testng</groupId>
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 50b8465..bfc2316 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,21 +17,21 @@
 package org.geant.shibboleth.plugin.openidvci.credential;
 
 import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 import javax.annotation.Nonnull;
 
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
-import net.shibboleth.shared.resource.Resource;
-
 /**
- * Parses supported credential configurations.
+ * Supported credential configurations.
  * 
  * See
  * {@link https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p}
@@ -39,6 +39,10 @@ import net.shibboleth.shared.resource.Resource;
  * 
  * Parsed credential configuration is published to clients and used as a
  * instruction on how credentials are wrapped to desired format.
+ * 
+ * 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.
  */
 public class CredentialConfigurations {
 
@@ -46,18 +50,30 @@ public class CredentialConfigurations {
     @Nonnull
     private static Logger log = LoggerFactory.getLogger(CredentialConfigurations.class);
 
+    /** Parser of the source. */
+    @Nonnull
+    private static final ObjectMapper mapper = new ObjectMapper();
+
     /** Credential configuration content. */
     @Nonnull
     private final Map<String, CredentialConfiguration> credentialConfigurations;
 
+    /** Credential configuration content as it was written. */
+    @Nonnull
+    private final Map<String, Object> supported;
+
     /**
      * Constructor .
      * 
-     * @param configurations Credential Configuration content
+     * @param credentialConfigurations Credential Configuration content
+     * @param source                   Credential Configuration content as it was
+     *                                 written
      */
-    private CredentialConfigurations(@Nonnull Map<String, CredentialConfiguration> credentialConfigurations) {
+    private CredentialConfigurations(@Nonnull Map<String, CredentialConfiguration> credentialConfigurations,
+            @Nonnull Map<String, Object> source) {
         assert credentialConfigurations != null;
         this.credentialConfigurations = credentialConfigurations;
+        supported = source;
     }
 
     public Map<String, CredentialConfiguration> getCredentialConfigurations() {
@@ -65,23 +81,59 @@ public class CredentialConfigurations {
     }
 
     /**
-     * Parses credential configuration from resource.
+     * Get credential configuration content as it was written.
+     * 
+     * This is the value of the 'credential_configurations_supported' member as
+     * published, it is not rewritten to what this plugin models.
      * 
-     * @param resource Credential configuration
+     * @return unmodifiable content, in the order it was written
+     */
+    @Nonnull
+    public Map<String, Object> getSupported() {
+        return supported;
+    }
+
+    /**
+     * Parses credential configuration.
+     * 
+     * @param bytes Credential configuration
      * @return CredentialConfigurations instance.
-     * @throws IOException if something unexpected occurs.
+     * @throws IOException if the source is not a JSON object, if it declares no
+     *                     credential configuration, or if a configuration cannot be
+     *                     parsed
      */
-    public static CredentialConfigurations parse(@Nonnull @NotEmpty final Resource resource) throws IOException {
-
-        assert resource != null;
-        assert resource.exists();
-        Map<String, Object> configurations = new ObjectMapper().readValue(
-                new String(resource.getInputStream().readAllBytes()), new TypeReference<Map<String, Object>>() {
-                });
-        return new CredentialConfigurations(new ObjectMapper().readValue(
-                new ObjectMapper().writeValueAsString(configurations.get("credential_configurations_supported")),
-                new TypeReference<Map<String, CredentialConfiguration>>() {
-                }));
+    @Nonnull
+    public static CredentialConfigurations parse(@Nonnull final byte[] bytes) throws IOException {
+
+        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");
+        }
+
+        final Object configurations = document.get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+        if (!(configurations instanceof Map)) {
+            throw new IOException("Credential configuration has no '"
+                    + CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED + "' object");
+        }
+        if (((Map<?, ?>) configurations).isEmpty()) {
+            throw new IOException("Credential configuration declares no credential configuration");
+        }
+
+        final Map<String, CredentialConfiguration> parsed;
+        try {
+            parsed = mapper.convertValue(configurations, new TypeReference<Map<String, CredentialConfiguration>>() {
+            });
+        } catch (final IllegalArgumentException e) {
+            throw new IOException("Unable to parse a credential configuration: " + e.getMessage(), e);
+        }
+
+        log.debug("Parsed {} credential configuration(s): {}", parsed.size(), parsed.keySet());
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> source = (Map<String, Object>) configurations;
+        return new CredentialConfigurations(Collections.unmodifiableMap(parsed),
+                Collections.unmodifiableMap(source));
     }
 
 }
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java
new file mode 100644
index 0000000..365f796
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/CredentialConfigurationsResolver.java
@@ -0,0 +1,31 @@
+/*
+ * 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.resolver;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the {@link CredentialConfigurations} this deployment issues from.
+ */
+public interface CredentialConfigurationsResolver
+        extends Resolver<CredentialConfigurations, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java
new file mode 100644
index 0000000..a2c0d7c
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/resolver/RefreshableCredentialConfigurationsResolver.java
@@ -0,0 +1,57 @@
+/*
+ * 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.resolver;
+
+import java.time.Instant;
+
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Specialization of {@link CredentialConfigurationsResolver} that supports
+ * on-demand refresh.
+ */
+public interface RefreshableCredentialConfigurationsResolver extends CredentialConfigurationsResolver {
+
+    /**
+     * Refresh the data exposed by the resolver.
+     *
+     * @throws ResolverException if the refresh operation was unsuccessful
+     */
+    void refresh() throws ResolverException;
+
+    /**
+     * Get the time the last refresh cycle occurred.
+     *
+     * @return time the last refresh cycle occurred
+     */
+    @Nullable
+    Instant getLastRefresh();
+
+    /**
+     * Get the time the currently available configurations were last updated. This
+     * may be earlier than {@link #getLastRefresh()}, a refresh cycle that found
+     * nothing changed does not update it.
+     *
+     * @return time the currently available configurations were last updated, or
+     *         null if they have never been read in successfully
+     */
+    @Nullable
+    Instant getLastUpdate();
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
new file mode 100644
index 0000000..bc54c94
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadata.java
@@ -0,0 +1,135 @@
+/*
+ * 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.metadata;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+/**
+ * Credential Issuer metadata as in <a href=
+ * "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ *
+ * Members are carried as they were parsed, this is the document that gets
+ * published. Only 'credential_issuer' is parsed, it names the document.
+ * Everything else is the deployer's to write.
+ */
+public class CredentialIssuerMetadata {
+
+    /** Member naming the Credential Issuer. */
+    @Nonnull
+    public static final String CREDENTIAL_ISSUER = "credential_issuer";
+
+    /** Member naming the Credential Endpoint. */
+    @Nonnull
+    public static final String CREDENTIAL_ENDPOINT = "credential_endpoint";
+
+    /** Member carrying the Credential Configurations. */
+    @Nonnull
+    public static final String CREDENTIAL_CONFIGURATIONS_SUPPORTED = "credential_configurations_supported";
+
+    /** Members of the document, in the order they were parsed. */
+    @Nonnull
+    private final Map<String, Object> members;
+
+    /** Credential Issuer the document is for. */
+    @Nonnull
+    private final Issuer credentialIssuer;
+
+    /**
+     * Constructor.
+     *
+     * @param parsed members of the document
+     * @param issuer Credential Issuer the document is for
+     */
+    private CredentialIssuerMetadata(@Nonnull final Map<String, Object> parsed, @Nonnull final Issuer issuer) {
+        members = parsed;
+        credentialIssuer = issuer;
+    }
+
+    /**
+     * Get the Credential Issuer the document is for.
+     *
+     * @return the Credential Issuer
+     */
+    @Nonnull
+    public Issuer getCredentialIssuer() {
+        return credentialIssuer;
+    }
+
+    /**
+     * Get the members of the document.
+     *
+     * @return unmodifiable members, in the order they were parsed
+     */
+    @Nonnull
+    public Map<String, Object> getMembers() {
+        return members;
+    }
+
+    /**
+     * Get a modifiable copy of the members, to build another document out of this
+     * one.
+     *
+     * @return copy of the members, in the order they were parsed
+     */
+    @Nonnull
+    public Map<String, Object> toModifiableMembers() {
+        return new LinkedHashMap<>(members);
+    }
+
+    /**
+     * Get whether the document has a member.
+     *
+     * @param member member to look for
+     * @return whether the member is present
+     */
+    public boolean hasMember(@Nonnull final String member) {
+        return members.containsKey(member);
+    }
+
+    /**
+     * Parse a document.
+     *
+     * @param members members of the document
+     * @return the document
+     * @throws ParseException if the members do not name a Credential Issuer
+     */
+    @Nonnull
+    public static CredentialIssuerMetadata parse(@Nullable final Map<String, Object> members) throws ParseException {
+
+        if (members == null) {
+            throw new ParseException("Credential Issuer metadata cannot be null");
+        }
+
+        final Object issuer = members.get(CREDENTIAL_ISSUER);
+        if (!(issuer instanceof String) || ((String) issuer).isEmpty()) {
+            throw new ParseException(
+                    "Credential Issuer metadata must have a non empty '" + CREDENTIAL_ISSUER + "' member");
+        }
+        return new CredentialIssuerMetadata(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
+                new Issuer((String) issuer));
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..ce19565
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataResolver.java
@@ -0,0 +1,31 @@
+/*
+ * 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.metadata.resolver;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the {@link CredentialIssuerMetadata} this deployment publishes.
+ */
+public interface CredentialIssuerMetadataResolver
+        extends Resolver<CredentialIssuerMetadata, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java
new file mode 100644
index 0000000..03c08f0
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/CredentialIssuerMetadataValueResolver.java
@@ -0,0 +1,30 @@
+/*
+ * 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.metadata.resolver;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of the value of Credential Issuer metadata.
+ */
+public interface CredentialIssuerMetadataValueResolver
+        extends Resolver<Object, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java
new file mode 100644
index 0000000..c50cb6c
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/RefreshableCredentialIssuerMetadataValueResolver.java
@@ -0,0 +1,57 @@
+/*
+ * 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.metadata.resolver;
+
+import java.time.Instant;
+
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Specialization of {@link CredentialIssuerMetadataValueResolver} that supports
+ * on-demand refresh.
+ */
+public interface RefreshableCredentialIssuerMetadataValueResolver extends CredentialIssuerMetadataValueResolver {
+
+    /**
+     * Refresh the data exposed by the resolver.
+     *
+     * @throws ResolverException if the refresh operation was unsuccessful
+     */
+    void refresh() throws ResolverException;
+
+    /**
+     * Get the time the last refresh cycle occurred.
+     *
+     * @return time the last refresh cycle occurred
+     */
+    @Nullable
+    Instant getLastRefresh();
+
+    /**
+     * Get the time the currently available value was last updated. This may be
+     * earlier than {@link #getLastRefresh()}, a refresh cycle that found nothing
+     * changed does not update it.
+     *
+     * @return time the currently available value was last updated, or null if it
+     *         has never been read in successfully
+     */
+    @Nullable
+    Instant getLastUpdate();
+
+}
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 484b893..282e17e 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
@@ -17,6 +17,8 @@
 package org.geant.shibboleth.plugin.openidvci.credential;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
 
 import org.springframework.core.io.ClassPathResource;
 import org.testng.Assert;
@@ -33,13 +35,42 @@ public class CredentialConfigurationsTest {
     public void parse() throws IOException {
 
         CredentialConfigurations configurations = CredentialConfigurations
-                .parse(ResourceHelper.of(new ClassPathResource("credentials/CredentialConfigurationsSupported.json")));
+                .parse(ResourceHelper.of(new ClassPathResource("credentials/CredentialConfigurationsSupported.json"))
+                        .getInputStream().readAllBytes());
 
         CredentialConfiguration configuration = configurations.getCredentialConfigurations().get("GeantIncubatorSdJwt");
         Assert.assertEquals(configuration.getFormat(), "dc+sd-jwt");
-        Assert.assertEquals(configuration.getScope(),"SD_JWT_VC_example_in_OpenID4VCI");
-        Assert.assertEquals(configuration.getCredentialSigningAlgValuesSupported().get(0),"ES256");
+        Assert.assertEquals(configuration.getScope(), "SD_JWT_VC_example_in_OpenID4VCI");
+        Assert.assertEquals(configuration.getCredentialSigningAlgValuesSupported().get(0), "ES256");
         Assert.assertEquals(configuration.getCryptographicBindingMethodsSupported().get(0), "jwk");
+
+        // Same source, kept as written for publishing.
+        Assert.assertEquals(configurations.getSupported().keySet(),
+                configurations.getCredentialConfigurations().keySet());
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> source = (Map<String, Object>) configurations.getSupported()
+                .get("GeantIncubatorSdJwt");
+        Assert.assertEquals(source.get("format"), "dc+sd-jwt");
+    }
+
+    @Test
+    public void parseNoCredentialConfigurations() {
+        Assert.assertThrows(IOException.class,
+                () -> CredentialConfigurations.parse("{\"something_else\":{}}".getBytes(StandardCharsets.UTF_8)));
+    }
+
+    @Test
+    public void parseEmptyCredentialConfigurations() {
+        Assert.assertThrows(IOException.class, () -> CredentialConfigurations
+                .parse("{\"credential_configurations_supported\":{}}".getBytes(StandardCharsets.UTF_8)));
+    }
+
+    @Test
+    public void parseUnparsableCredentialConfiguration() {
+        Assert.assertThrows(IOException.class,
+                () -> CredentialConfigurations.parse(("{\"credential_configurations_supported\":"
+                        + "{\"Broken\":{\"format\":\"dc+sd-jwt\",\"proof_types_supported\":\"not an object\"}}}")
+                        .getBytes(StandardCharsets.UTF_8)));
     }
 
 }
\ No newline at end of file
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java
new file mode 100644
index 0000000..2d5985a
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/CredentialIssuerMetadataTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.metadata;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Unit tests for {@link CredentialIssuerMetadata}.
+ */
+public class CredentialIssuerMetadataTest {
+
+    private Map<String, Object> members() {
+        final Map<String, Object> members = new LinkedHashMap<>();
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "https://example.org");
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "https://example.org/credentials");
+        members.put("display", List.of(Map.of("name", "Example")));
+        return members;
+    }
+
+    @Test
+    public void testSuccess() throws ParseException {
+        final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+        Assert.assertTrue(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT));
+        Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+    }
+
+    @Test
+    public void testMemberOrderIsPreserved() throws ParseException {
+        final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+        Assert.assertEquals(new ArrayList<>(metadata.getMembers().keySet()), List.of(
+                CredentialIssuerMetadata.CREDENTIAL_ISSUER, CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "display"));
+    }
+
+    @Test
+    public void testMembersAreNotModifiable() throws ParseException {
+        final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+        Assert.assertThrows(UnsupportedOperationException.class, () -> metadata.getMembers().put("x", "y"));
+    }
+
+    @Test
+    public void testParsedDocumentIsDetachedFromInput() throws ParseException {
+        final Map<String, Object> input = members();
+        final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(input);
+
+        input.put("nonce_endpoint", "https://example.org/nonce");
+
+        Assert.assertFalse(metadata.hasMember("nonce_endpoint"));
+    }
+
+    @Test
+    public void testModifiableMembersAreACopy() throws ParseException {
+        final CredentialIssuerMetadata metadata = CredentialIssuerMetadata.parse(members());
+
+        final Map<String, Object> copy = metadata.toModifiableMembers();
+        copy.put(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, Map.of());
+
+        Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+    }
+
+    @Test
+    public void testNoMembers() {
+        Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(null));
+    }
+
+    @Test
+    public void testNoCredentialIssuer() {
+        final Map<String, Object> members = members();
+        members.remove(CredentialIssuerMetadata.CREDENTIAL_ISSUER);
+
+        Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+    }
+
+    @Test
+    public void testEmptyCredentialIssuer() {
+        final Map<String, Object> members = members();
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "");
+
+        Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+    }
+
+    @Test
+    public void testCredentialIssuerNotAString() {
+        final Map<String, Object> members = members();
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, List.of("https://example.org"));
+
+        Assert.assertThrows(ParseException.class, () -> CredentialIssuerMetadata.parse(members));
+    }
+
+}
diff --git a/openid-vci-impl/pom.xml b/openid-vci-impl/pom.xml
index f94b128..1835c69 100644
--- a/openid-vci-impl/pom.xml
+++ b/openid-vci-impl/pom.xml
@@ -36,11 +36,31 @@
             <artifactId>idp-admin-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${shib-shared.groupId}</groupId>
+            <artifactId>shib-velocity</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <!--
+        Interface of the OpenID Federation entity configuration decorator. Compile only,
+        the decorator bean is created only when a deployer imports openid-vci-oidfed.xml.
+        -->
+        <dependency>
+            <groupId>${oidfed-common.groupId}</groupId>
+            <artifactId>oidfed-common-api</artifactId>
+            <version>${oidfed-common.version}</version>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>${shib-shared.groupId}.oidc</groupId>
             <artifactId>oidc-common-metadata-api</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>${shib-shared.groupId}.oidc</groupId>
+            <artifactId>oidc-common-metadata-impl</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>net.shibboleth.idp.plugin.oidc</groupId>
             <artifactId>idp-plugin-oidc-op-api</artifactId>
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java
new file mode 100644
index 0000000..e429639
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/impl/DefaultOpenIDVCIIssuerMetadataConfiguration.java
@@ -0,0 +1,49 @@
+/*
+ * 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.config.impl;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint publishing Credential Issuer metadata,
+ * as in
+ * <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ *
+ */
+public class DefaultOpenIDVCIIssuerMetadataConfiguration extends AbstractInterceptorAwareProfileConfiguration {
+
+    /** OpenID4VCI specification URI. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROTOCOL_URI =
+            "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html";
+
+    /** ID for Credential Issuer metadata profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID = "http://geant.org/ns/profiles/openid/vci/issuer-metadata";
+
+    /** Constructor. */
+    public DefaultOpenIDVCIIssuerMetadataConfiguration() {
+        super(PROFILE_ID);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java
new file mode 100644
index 0000000..6607f97
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolver.java
@@ -0,0 +1,110 @@
+/*
+ * 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.impl;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Identifier;
+
+import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Credential Configurations read from a file, with the refresh behaviour of
+ * {@link AbstractFileOIDCEntityResolver}.
+ *
+ */
+public class FilesystemCredentialConfigurationsResolver
+        extends AbstractFileOIDCEntityResolver<Identifier, CredentialConfigurations>
+        implements RefreshableCredentialConfigurationsResolver {
+
+    /** Key of the single value in the backing store. */
+    @Nonnull
+    private static final Identifier KEY = new Identifier("credential configurations");
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(FilesystemCredentialConfigurationsResolver.class);
+
+    /**
+     * Constructor.
+     *
+     * @param credentials the Credential Configurations file
+     *
+     * @throws IOException if the file cannot be loaded
+     */
+    public FilesystemCredentialConfigurationsResolver(@Nonnull final Resource credentials) throws IOException {
+        super(credentials);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Iterable<CredentialConfigurations> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        ifNotInitializedThrowUninitializedComponentException();
+        ifDestroyedThrowDestroyedComponentException();
+
+        return getBackingStore().getOrderedInformation();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public CredentialConfigurations resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        final Iterator<CredentialConfigurations> iterator = resolve(profileRequestContext).iterator();
+        if (iterator.hasNext()) {
+            return iterator.next();
+        }
+        log.warn("{} No Credential Configurations have been read in", getLogPrefix());
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected List<CredentialConfigurations> parse(@Nonnull final byte[] bytes) throws ParseException {
+        try {
+            return CollectionSupport.listOf(CredentialConfigurations.parse(bytes));
+        } catch (final IOException e) {
+            throw new ParseException(e.getMessage(), e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected Identifier getKey(@Nonnull final CredentialConfigurations value) {
+        return KEY;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java
new file mode 100644
index 0000000..733320a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java
@@ -0,0 +1,67 @@
+/*
+ * 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.messaging.impl;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying the Credential Issuer metadata, as in <a href=
+ * "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata">
+ * OpenID4VCI</a>.
+ */
+public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse {
+
+    /** Serialized metadata document. */
+    @Nonnull
+    private final String content;
+
+    /**
+     * Constructor.
+     *
+     * @param metadata members of the metadata document
+     *
+     * @throws JsonProcessingException if the members cannot be serialized
+     */
+    public CredentialIssuerMetadataSuccessResponse(@Nonnull final Map<String, Object> metadata)
+            throws JsonProcessingException {
+        content = new ObjectMapper().writeValueAsString(metadata);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean indicatesSuccess() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPResponse toHTTPResponse() {
+        final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+        httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+        httpResponse.setContent(content);
+        return httpResponse;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java
new file mode 100644
index 0000000..75bca86
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolver.java
@@ -0,0 +1,120 @@
+/*
+ * 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.metadata.impl;
+
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.RefreshableCredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Resolver serving the 'credential_configurations_supported' member of the
+ * published Credential Issuer metadata from the
+ * {@link CredentialConfigurationsResolver} the issuance flows read.
+ *
+ * The member is published as the deployer wrote it.
+ */
+public class CredentialConfigurationsValueResolver extends AbstractIdentifiableInitializableComponent
+        implements RefreshableCredentialIssuerMetadataValueResolver {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(CredentialConfigurationsValueResolver.class);
+
+    /** Resolver of the Credential Configurations to publish. */
+    @Nonnull
+    private final CredentialConfigurationsResolver credentialConfigurationsResolver;
+
+    /**
+     * Constructor.
+     *
+     * @param credentialConfigurations resolver of the Credential Configurations to
+     *                                 publish
+     */
+    public CredentialConfigurationsValueResolver(
+            @Nonnull final CredentialConfigurationsResolver credentialConfigurations) {
+        credentialConfigurationsResolver = Constraint.isNotNull(credentialConfigurations,
+                "Credential Configurations resolver cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Iterable<Object> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        final Object configurations = resolveSingle(profileRequestContext);
+        return configurations == null ? CollectionSupport.emptyList() : CollectionSupport.listOf(configurations);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public Object resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        checkComponentActive();
+
+        final CredentialConfigurations configurations =
+                credentialConfigurationsResolver.resolveSingle(profileRequestContext);
+        if (configurations == null) {
+            log.warn("{} No Credential Configurations to publish", getId());
+            return null;
+        }
+        return configurations.getSupported();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void refresh() throws ResolverException {
+        if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+            refreshable.refresh();
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public Instant getLastRefresh() {
+        if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+            return refreshable.getLastRefresh();
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public Instant getLastUpdate() {
+        if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+            return refreshable.getLastUpdate();
+        }
+        return null;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..2c2a255
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolver.java
@@ -0,0 +1,131 @@
+/*
+ * 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.metadata.impl;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataValueResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.RefreshableCredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Extension to {@link FilesystemCredentialIssuerMetadataResolver} that lets
+ * some members of the published document be resolved outside the file.
+ */
+public class DynamicFilesystemCredentialIssuerMetadataResolver extends FilesystemCredentialIssuerMetadataResolver {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(DynamicFilesystemCredentialIssuerMetadataResolver.class);
+
+    /** Resolvers of members resolved outside the file, keyed by member name. */
+    @Nonnull
+    private Map<String, ? extends CredentialIssuerMetadataValueResolver> dynamicResolvers = new HashMap<>();
+
+    /**
+     * Constructor.
+     *
+     * @param metadata the metadata file
+     *
+     * @throws IOException if the metadata cannot be loaded
+     */
+    public DynamicFilesystemCredentialIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+        super(metadata);
+    }
+
+    /**
+     * Set the resolvers of members resolved outside the file.
+     *
+     * @param map resolvers keyed by the member each one resolves
+     */
+    public void setDynamicValueResolvers(
+            @Nonnull final Map<String, ? extends CredentialIssuerMetadataValueResolver> map) {
+        dynamicResolvers = Constraint.isNotNull(map, "The map of dynamic metadata resolvers cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+
+        final List<CredentialIssuerMetadata> result = new ArrayList<>();
+        for (final CredentialIssuerMetadata entity : super.resolve(profileRequestContext)) {
+            final Map<String, Object> members = entity.toModifiableMembers();
+
+            for (final String member : dynamicResolvers.keySet()) {
+                log.debug("{} Starting to resolve value for {}", getLogPrefix(), member);
+                try {
+                    final CredentialIssuerMetadataValueResolver resolver = dynamicResolvers.get(member);
+                    if (resolver instanceof RefreshableCredentialIssuerMetadataValueResolver refreshable) {
+                        refreshable.refresh();
+                    }
+                    final Object value = resolver.resolveSingle(profileRequestContext);
+                    if (value != null) {
+                        members.put(member, value);
+                        log.debug("{} Member {} updated to the result", getLogPrefix(), member);
+                    }
+                } catch (final ResolverException e) {
+                    log.warn("{} Could not resolve a value for {}, ignoring it", getLogPrefix(), member, e);
+                }
+            }
+
+            try {
+                result.add(CredentialIssuerMetadata.parse(members));
+            } catch (final ParseException e) {
+                log.warn("{} The resulting Credential Issuer metadata is not valid, ignoring it", getLogPrefix(), e);
+            }
+        }
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Instant getMetadataUpdateTime() {
+        Instant updateTime = super.getMetadataUpdateTime();
+        for (final CredentialIssuerMetadataValueResolver resolver : dynamicResolvers.values()) {
+            if (resolver instanceof RefreshableCredentialIssuerMetadataValueResolver refreshable) {
+                final Instant lastUpdate = refreshable.getLastUpdate();
+                if (lastUpdate == null) {
+                    return Instant.now();
+                }
+                if (lastUpdate.isAfter(updateTime)) {
+                    updateTime = lastUpdate;
+                }
+            }
+        }
+        return updateTime;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
new file mode 100644
index 0000000..0fa2b57
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
@@ -0,0 +1,300 @@
+/*
+ * 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.metadata.impl;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.exception.VelocityException;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+import org.springframework.core.io.Resource;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+import net.shibboleth.shared.velocity.Template;
+
+/**
+ * Credential Issuer metadata read from a file, with the refresh behaviour of
+ * {@link AbstractFileOIDCEntityResolver}.
+ */
+public class FilesystemCredentialIssuerMetadataResolver extends
+        AbstractFileOIDCEntityResolver<Issuer, CredentialIssuerMetadata> implements CredentialIssuerMetadataResolver {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(FilesystemCredentialIssuerMetadataResolver.class);
+
+    /** Variable carrying the issuer as configured. */
+    @Nonnull
+    private static final String ISSUER_VARIABLE = "issuer";
+
+    /** Variable carrying scheme and authority of the issuer. */
+    @Nonnull
+    private static final String BASE_URL_VARIABLE = "baseUrl";
+
+    /** Variable carrying the host of the issuer. */
+    @Nonnull
+    private static final String HOST_VARIABLE = "host";
+
+    /** Reference Velocity leaves in place when it resolves to nothing. */
+    @Nonnull
+    private static final Pattern UNRESOLVED = Pattern.compile("\\$\\{?[A-Za-z_]");
+
+    /** Parser of the metadata file. */
+    @Nonnull
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** Engine evaluating the metadata file, or null to read the file as it is. */
+    @Nullable
+    private VelocityEngine velocityEngine;
+
+    /** Issuer the variables of the template are derived from. */
+    @Nullable
+    private String issuer;
+
+    /** Variables the deployer adds to the template. */
+    @Nullable
+    private Map<String, ?> templateContext;
+
+    /** Variables offered to the template. */
+    @Nonnull
+    private Map<String, Object> variables = Map.of();
+
+    /**
+     * Constructor.
+     *
+     * @param metadata the metadata file
+     *
+     * @throws IOException if the metadata cannot be loaded
+     */
+    public FilesystemCredentialIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+        super(metadata);
+    }
+
+    /**
+     * Set the engine evaluating the metadata file.
+     *
+     * Leaving this unset reads the file as it is, for a document that carries '$'
+     * or '#' as content.
+     *
+     * @param engine engine to use, or null to read the file as it is
+     */
+    public void setVelocityEngine(@Nullable final VelocityEngine engine) {
+        checkSetterPreconditions();
+        velocityEngine = engine;
+    }
+
+    /**
+     * Set the issuer the variables of the template are derived from.
+     *
+     * This is the deployment's issuer, so '$baseUrl' names this host even when the
+     * issuer itself carries a path.
+     *
+     * @param identifier issuer to derive the variables from
+     */
+    public void setIssuer(@Nullable final String identifier) {
+        checkSetterPreconditions();
+        issuer = identifier;
+    }
+
+    /**
+     * Set the variables the deployer adds to the template.
+     *
+     * These are applied after the derived ones, so a deployer may also replace
+     * those.
+     *
+     * @param context variables to add
+     */
+    public void setTemplateContext(@Nullable final Map<String, ?> context) {
+        checkSetterPreconditions();
+        templateContext = context;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        resolveVariables();
+        super.doInitialize();
+    }
+
+    /**
+     * Resolve the variables offered to the template.
+     *
+     * @throws ComponentInitializationException if the issuer does not name a host
+     */
+    private void resolveVariables() throws ComponentInitializationException {
+
+        if (velocityEngine == null) {
+            return;
+        }
+
+        final Map<String, Object> resolved = new LinkedHashMap<>();
+        if (issuer != null && !issuer.isEmpty()) {
+            final URI uri;
+            try {
+                uri = new URI(issuer);
+            } catch (final URISyntaxException e) {
+                throw new ComponentInitializationException("Issuer '" + issuer + "' is not a uri", e);
+            }
+            if (uri.getScheme() == null || uri.getRawAuthority() == null) {
+                throw new ComponentInitializationException("Issuer '" + issuer + "' names no host");
+            }
+            resolved.put(ISSUER_VARIABLE, issuer);
+            resolved.put(BASE_URL_VARIABLE, uri.getScheme() + "://" + uri.getRawAuthority());
+            resolved.put(HOST_VARIABLE, uri.getHost());
+        }
+        if (templateContext != null) {
+            resolved.putAll(templateContext);
+        }
+        variables = resolved;
+
+        log.debug("Credential Issuer metadata template variables {}", resolved);
+    }
+
+    /**
+     * Evaluate the metadata template.
+     *
+     * @param template template to evaluate
+     * @return the evaluated document
+     * @throws ParseException if the template cannot be evaluated
+     */
+    @Nonnull
+    private String evaluate(@Nonnull final String template) throws ParseException {
+
+        assert velocityEngine != null;
+
+        if (template.isBlank()) {
+            throw new ParseException("Credential Issuer metadata template is empty");
+        }
+
+        final String document;
+        Template compiled = null;
+        try {
+            compiled = Template.fromTemplate(velocityEngine, template, StandardCharsets.UTF_8);
+            final VelocityContext context = new VelocityContext();
+            variables.forEach(context::put);
+            document = compiled.merge(context);
+        } catch (final VelocityException e) {
+            throw new ParseException("Unable to evaluate Credential Issuer metadata template: " + e.getMessage(), e);
+        } finally {
+            // fromTemplate registers the template in a repository that outlives it.
+            if (compiled != null) {
+                StringResourceLoader.getRepository().removeStringResource(compiled.getTemplateName());
+            }
+        }
+
+        if (UNRESOLVED.matcher(document).find()) {
+            log.warn(
+                    "{} Credential Issuer metadata still reads as a template after evaluating it. A reference to "
+                            + "something other than {} resolves to nothing and is left in place.",
+                    getLogPrefix(), variables.keySet());
+        }
+
+        return document;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        ifNotInitializedThrowUninitializedComponentException();
+        ifDestroyedThrowDestroyedComponentException();
+
+        return getBackingStore().getOrderedInformation();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public CredentialIssuerMetadata resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
+            throws ResolverException {
+        final Iterator<CredentialIssuerMetadata> iterator = resolve(profileRequestContext).iterator();
+        if (iterator.hasNext()) {
+            return iterator.next();
+        }
+        log.warn("{} No Credential Issuer metadata has been read in", getLogPrefix());
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected List<CredentialIssuerMetadata> parse(@Nonnull final byte[] bytes) throws ParseException {
+        return CollectionSupport.listOf(CredentialIssuerMetadata.parse(readMembers(bytes)));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected Issuer getKey(@Nonnull final CredentialIssuerMetadata value) {
+        return value.getCredentialIssuer();
+    }
+
+    /**
+     * Read the members of a metadata document.
+     *
+     * @param bytes document to read
+     * @return members of the document, in the order they appear in it
+     * @throws ParseException if the bytes are not a JSON object
+     */
+    @Nonnull
+    protected Map<String, Object> readMembers(@Nonnull final byte[] bytes) throws ParseException {
+
+        String document = new String(bytes, StandardCharsets.UTF_8);
+        if (velocityEngine != null) {
+            document = evaluate(document);
+        }
+
+        try {
+            final Map<String, Object> members = objectMapper.readValue(document,
+                    new TypeReference<LinkedHashMap<String, Object>>() {
+                    });
+            if (members == null) {
+                throw new ParseException("Credential Issuer metadata is not a JSON object");
+            }
+            return members;
+        } catch (final IOException e) {
+            throw new ParseException("Unable to parse Credential Issuer metadata: " + e.getMessage(), e);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java
new file mode 100644
index 0000000..b4fb3ff
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecorator.java
@@ -0,0 +1,306 @@
+/*
+ * 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.oidfed.impl;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.jwk.JWK;
+
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecorator;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * An {@link EntityConfigurationMetadataDecorator} that states this deployment
+ * is a Credential Issuer.
+ *
+ */
+public class CredentialIssuerEntityConfigurationMetadataDecorator extends AbstractIdentifiableInitializableComponent
+        implements EntityConfigurationMetadataDecorator {
+
+    /** Entity type carrying the Credential Issuer metadata. */
+    @Nonnull
+    public static final String OPENID_CREDENTIAL_ISSUER = "openid_credential_issuer";
+
+    /** Entity type carrying the keys Credentials are signed with. */
+    @Nonnull
+    public static final String VC_ISSUER = "vc_issuer";
+
+    /** Entity type carrying the display properties of the entity. */
+    @Nonnull
+    public static final String FEDERATION_ENTITY = "federation_entity";
+
+    /** Member carrying a JWK Set. */
+    @Nonnull
+    public static final String JWKS = "jwks";
+
+    /** Member of a JWK Set carrying the keys. */
+    @Nonnull
+    public static final String KEYS = "keys";
+
+    /** Member carrying the algorithm of a key. */
+    @Nonnull
+    public static final String ALG = "alg";
+
+    /** Member of Credential Issuer metadata carrying display properties. */
+    @Nonnull
+    public static final String DISPLAY = "display";
+
+    /** Member of display properties carrying a name. */
+    @Nonnull
+    public static final String NAME = "name";
+
+    /** Member of display properties carrying a logo. */
+    @Nonnull
+    public static final String LOGO = "logo";
+
+    /** Member of a logo carrying its location. */
+    @Nonnull
+    public static final String URI = "uri";
+
+    /** Member of federation_entity carrying a display name. */
+    @Nonnull
+    public static final String DISPLAY_NAME = "display_name";
+
+    /** Member of federation_entity carrying a logo location. */
+    @Nonnull
+    public static final String LOGO_URI = "logo_uri";
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(CredentialIssuerEntityConfigurationMetadataDecorator.class);
+
+    /** Resolver of the Credential Issuer metadata to state. */
+    @NonnullAfterInit
+    private CredentialIssuerMetadataResolver metadataResolver;
+
+    /** Keys Credentials are signed with. */
+    @NonnullAfterInit
+    private List<Credential> credentialSigningCredentials;
+
+    /**
+     * Whether display properties of the Credential Issuer fill federation_entity.
+     */
+    private boolean decorateFederationEntity = true;
+
+    /**
+     * Set the resolver of the Credential Issuer metadata to state.
+     *
+     * @param resolver resolver to use
+     */
+    public void setMetadataResolver(@Nonnull final CredentialIssuerMetadataResolver resolver) {
+        checkSetterPreconditions();
+        metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
+    }
+
+    /**
+     * Set the keys Credentials are signed with.
+     *
+     * These must be the keys Credentials are actually signed with, one stated here
+     * and not used leaves a verifier unable to read a Credential. So this takes the
+     * very list the Credential signing configuration takes, rather than a list of
+     * its own that could be edited to say something else.
+     *
+     * @param credentials keys to state, may be null when none are declared
+     */
+    public void setCredentialSigningCredentials(@Nullable final List<Credential> credentials) {
+        checkSetterPreconditions();
+        credentialSigningCredentials = credentials == null ? CollectionSupport.emptyList()
+                : CollectionSupport.copyToList(credentials);
+    }
+
+    /**
+     * Set whether display properties of the Credential Issuer fill
+     * federation_entity.
+     *
+     * Turn this off to state federation_entity entirely in the entity configuration
+     * metadata of the federation module.
+     *
+     * @param flag whether to fill federation_entity
+     */
+    public void setDecorateFederationEntity(final boolean flag) {
+        checkSetterPreconditions();
+        decorateFederationEntity = flag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (metadataResolver == null) {
+            throw new ComponentInitializationException("The metadata resolver cannot be null");
+        }
+        if (credentialSigningCredentials == null || credentialSigningCredentials.isEmpty()) {
+            throw new ComponentInitializationException("No keys resolvedt");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void accept(@Nullable final Map<String, Map<String, Object>> metadata,
+            @Nullable final ProfileRequestContext profileRequestContext) {
+        checkComponentActive();
+
+        if (metadata == null || profileRequestContext == null) {
+            log.error("Metadata {} nor profile request context {} cannot be null", metadata, profileRequestContext);
+            throw new ConstraintViolationException("Metadata nor profile request context cannot be null");
+        }
+
+        final CredentialIssuerMetadata credentialIssuer;
+        try {
+            credentialIssuer = metadataResolver.resolveSingle(profileRequestContext);
+        } catch (final ResolverException e) {
+            log.error("Could not resolve Credential Issuer metadata, entity configuration is left without it", e);
+            return;
+        }
+        if (credentialIssuer == null) {
+            log.error("Could not resolve Credential Issuer metadata, entity configuration is left without it");
+            return;
+        }
+
+        decorate(metadata, OPENID_CREDENTIAL_ISSUER, credentialIssuer.getMembers());
+
+        final List<Object> keys = credentialSigningKeys();
+        if (!keys.isEmpty()) {
+            decorate(metadata, VC_ISSUER, Map.of(JWKS, Map.of(KEYS, keys)));
+        } else {
+            log.error("None of the keys Credentials are signed with could be stated, entity configuration "
+                    + "is left without {}", VC_ISSUER);
+        }
+
+        if (decorateFederationEntity) {
+            fill(metadata, FEDERATION_ENTITY, displayProperties(credentialIssuer));
+        }
+    }
+
+    /**
+     * Get the public half of the keys Credentials are signed with, as a JWK Set
+     * document.
+     *
+     * @return the keys of the JWK Set
+     */
+    @Nonnull
+    private List<Object> credentialSigningKeys() {
+
+        final List<Object> keys = new ArrayList<>();
+        for (final Credential credential : credentialSigningCredentials) {
+            // Conversion keeps the public components only.
+            final JWK key = CredentialConversionUtil.credentialToKey(credential);
+            if (key == null) {
+                log.warn("Credential signing key is neither RSA nor EC, leaving it out of {}", VC_ISSUER);
+                continue;
+            }
+            final Map<String, Object> published = new LinkedHashMap<>(key.toJSONObject());
+            if (credential instanceof JWKCredential jwkCredential && jwkCredential.getAlgorithm() != null) {
+                published.put(ALG, jwkCredential.getAlgorithm().getName());
+            }
+            keys.add(published);
+        }
+
+        return keys;
+    }
+
+    /**
+     * Get the display properties of federation_entity, read from the display
+     * properties of the Credential Issuer metadata.
+     *
+     * @param credentialIssuer metadata to read
+     * @return the display properties, empty when the metadata declares none
+     */
+    @Nonnull
+    private Map<String, Object> displayProperties(@Nonnull final CredentialIssuerMetadata credentialIssuer) {
+
+        final Object display = credentialIssuer.getMembers().get(DISPLAY);
+        if (!(display instanceof List) || ((List<?>) display).isEmpty()) {
+            log.debug("Credential Issuer metadata declares no display properties for {}", FEDERATION_ENTITY);
+            return Map.of();
+        }
+
+        // First entry, it is the one without a locale by convention.
+        final Object first = ((List<?>) display).get(0);
+        if (!(first instanceof Map)) {
+            log.warn("Display properties of Credential Issuer metadata are not an object, leaving {} alone",
+                    FEDERATION_ENTITY);
+            return Map.of();
+        }
+        final Map<?, ?> properties = (Map<?, ?>) first;
+
+        final Map<String, Object> filled = new LinkedHashMap<>();
+        if (properties.get(NAME) != null) {
+            filled.put(DISPLAY_NAME, properties.get(NAME));
+        }
+        if (properties.get(LOGO) instanceof Map && ((Map<?, ?>) properties.get(LOGO)).get(URI) != null) {
+            filled.put(LOGO_URI, ((Map<?, ?>) properties.get(LOGO)).get(URI));
+        }
+        return filled;
+    }
+
+    /**
+     * Write members of an entity type, replacing the ones already there.
+     *
+     * @param metadata   entity configuration metadata to write to
+     * @param entityType entity type to write
+     * @param members    members to write
+     */
+    private void decorate(@Nonnull final Map<String, Map<String, Object>> metadata, @Nonnull final String entityType,
+            @Nonnull final Map<String, Object> members) {
+
+        if (metadata.containsKey(entityType)) {
+            metadata.get(entityType).putAll(members);
+        } else {
+            metadata.put(entityType, new LinkedHashMap<>(members));
+        }
+        log.debug("Stated entity type {} with members {}", entityType, members.keySet());
+    }
+
+    /**
+     * Write members of an entity type, keeping the ones already there.
+     *
+     * @param metadata   entity configuration metadata to write to
+     * @param entityType entity type to write
+     * @param members    members to write
+     */
+    private void fill(@Nonnull final Map<String, Map<String, Object>> metadata, @Nonnull final String entityType,
+            @Nonnull final Map<String, Object> members) {
+
+        final Map<String, Object> existing = metadata.computeIfAbsent(entityType, k -> new LinkedHashMap<>());
+        members.forEach(existing::putIfAbsent);
+        log.debug("Filled entity type {} with members {}", entityType, members.keySet());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
index e01b26e..689cf0b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AbstractCredentialValidationAction.java
@@ -16,29 +16,35 @@
 
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
-import java.io.IOException;
 import java.util.Map;
 
 import javax.annotation.Nonnull;
 
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
 import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.CredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.credential.resolver.RefreshableCredentialConfigurationsResolver;
 import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import net.shibboleth.shared.resource.Resource;
+
 import net.shibboleth.idp.profile.AbstractProfileAction;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
-import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.resolver.ResolverException;
 
 /**
  * Abstract class for actions performing actions on
- * {@link CredentialConfigurations} source from resource.
+ * {@link CredentialConfigurations} resolved for the request.
+ *
+ * The configurations come from a resolver shared with every other flow reading
+ * them, including the one publishing Credential Issuer metadata. The resolver is
+ * refreshed here, so a change to the source still takes effect without a
+ * restart, but an unchanged source is not read or parsed again.
  */
 abstract class AbstractCredentialValidationAction extends AbstractProfileAction {
 
@@ -46,32 +52,36 @@ abstract class AbstractCredentialValidationAction extends AbstractProfileAction
     @Nonnull
     private Logger log = LoggerFactory.getLogger(AbstractCredentialValidationAction.class);
 
-    /** The supported credentials file. */
+    /** Resolver of the supported credentials. */
     @NonnullAfterInit
-    private Resource credentialsResource;
+    private CredentialConfigurationsResolver credentialConfigurationsResolver;
 
-    /** The supported credentials parsed from file. */
+    /** The supported credentials resolved for this request. */
     @NonnullBeforeExec
     private CredentialConfigurations credentialConfigurations;
 
-    /** The supported credentials file. */
-    public void setCredentialsResource(@Nonnull @NotEmpty Resource resource) {
-        assert resource != null;
-        assert resource.exists();
-        credentialsResource = resource;
+    /**
+     * Set the resolver of the supported credentials.
+     *
+     * @param resolver resolver to use
+     */
+    public void setCredentialConfigurationsResolver(@Nonnull final CredentialConfigurationsResolver resolver) {
+        checkSetterPreconditions();
+        credentialConfigurationsResolver = Constraint.isNotNull(resolver,
+                "Credential Configurations resolver cannot be null");
     }
 
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
         super.doInitialize();
-        Constraint.isNotNull(credentialsResource, "Credentials resource cannot be null");
+        Constraint.isNotNull(credentialConfigurationsResolver, "Credential Configurations resolver cannot be null");
     }
 
     /**
-     * Get supported credentials parsed from file.
+     * Get supported credentials resolved for this request.
      * 
-     * @return Supported credentials parsed from file
+     * @return Supported credentials resolved for this request
      */
     public Map<String, CredentialConfiguration> getCredentialConfigurations() {
         return credentialConfigurations.getCredentialConfigurations();
@@ -80,14 +90,23 @@ abstract class AbstractCredentialValidationAction extends AbstractProfileAction
     /** {@inheritDoc} */
     @Override
     protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
         try {
-            credentialConfigurations = CredentialConfigurations.parse(credentialsResource);
-        } catch (IOException e) {
-            log.error("{} Parsing credential configuration failed.", getLogPrefix(), e);
+            if (credentialConfigurationsResolver instanceof RefreshableCredentialConfigurationsResolver refreshable) {
+                refreshable.refresh();
+            }
+            credentialConfigurations = credentialConfigurationsResolver.resolveSingle(profileRequestContext);
+        } catch (final ResolverException e) {
+            log.error("{} Resolving credential configuration failed.", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
+            return false;
+        }
+        if (credentialConfigurations == null) {
+            log.error("{} No credential configuration resolved.", getLogPrefix());
             ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
             return false;
         }
         return super.doPreExecute(profileRequestContext);
     }
 
-}
\ No newline at end of file
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
new file mode 100644
index 0000000..9d2e788
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessage.java
@@ -0,0 +1,140 @@
+/*
+ * 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 javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Action forming {@link CredentialIssuerMetadataSuccessResponse} from the
+ * document the attached {@link CredentialIssuerMetadataResolver} resolves.
+ *
+ */
+public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(FormOutboundIssuerMetadataResponseMessage.class);
+
+    /** Resolver of the metadata that is being published. */
+    @NonnullAfterInit
+    private CredentialIssuerMetadataResolver metadataResolver;
+
+    /** Metadata to publish. */
+    @NonnullBeforeExec
+    private CredentialIssuerMetadata metadata;
+
+    /**
+     * Set the resolver of the metadata that is being published.
+     *
+     * @param resolver resolver to use
+     */
+    public void setMetadataResolver(@Nonnull final CredentialIssuerMetadataResolver resolver) {
+        checkSetterPreconditions();
+        metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (metadataResolver == null) {
+            throw new ComponentInitializationException("The metadata resolver cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        try {
+            metadata = metadataResolver.resolveSingle(profileRequestContext);
+        } catch (final ResolverException e) {
+            log.error("{} Could not resolve Credential Issuer metadata", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return false;
+        }
+        if (metadata == null) {
+            log.error("{} Could not resolve Credential Issuer metadata", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return false;
+        }
+
+        final boolean endpoint = hasMember(profileRequestContext, CredentialIssuerMetadata.CREDENTIAL_ENDPOINT);
+        final boolean configurations = hasMember(profileRequestContext,
+                CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+
+        return endpoint && configurations;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        try {
+            profileRequestContext.ensureOutboundMessageContext()
+                    .setMessage(new CredentialIssuerMetadataSuccessResponse(metadata.getMembers()));
+        } catch (final JsonProcessingException e) {
+            log.error("{} Could not serialize Credential Issuer metadata", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return;
+        }
+
+        log.debug("{} Publishing Credential Issuer metadata of {}, members {}", getLogPrefix(),
+                metadata.getCredentialIssuer(), metadata.getMembers().keySet());
+    }
+
+    /**
+     * Check the document has a member, building an event when it does not.
+     *
+     * @param profileRequestContext current profile request context
+     * @param member                member the document cannot be published without
+     * @return whether the member is present
+     */
+    private boolean hasMember(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final String member) {
+        if (metadata.hasMember(member)) {
+            return true;
+        }
+        log.error("{} Credential Issuer metadata has no '{}' member, refusing to publish it", getLogPrefix(), member);
+        ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        return false;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 2b4f3f7..00f3c22 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -28,4 +28,72 @@
         class="org.geant.shibboleth.plugin.openidvci.profile.logic.OpenIDVCITokenManipulationStrategy" />
     
 
+    <!--
+    Resolver of the Credential Issuer metadata this deployment publishes. The document is
+    read from a file, apart from the members resolved outside it. Deployer may replace the
+    whole file with bean "openidvci.CredentialIssuerMetadata", or point
+    openidvci.issuerMetadata.template at another path.
+
+    The file is a Velocity template, so that the urls in it name this host without the file
+    being edited. Variables are $issuer, $baseUrl and $host, all derived from the issuer of
+    the OP. Define map bean "openidvci.issuerMetadata.TemplateContext" to add variables or
+    to replace those three. A document that carries '$' or '#' as content needs
+    openidvci.issuerMetadata.templated set to false, Velocity reads both of them.
+    -->
+    <bean id="openidvci.DefaultCredentialIssuerMetadataResolver" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.DynamicFilesystemCredentialIssuerMetadataResolver"
+        depends-on="shibboleth.LoggingService"
+        p:id="openidvci.DefaultCredentialIssuerMetadataResolver"
+        p:minRefreshDelay="%{openidvci.issuerMetadata.minRefreshDelay:PT5M}"
+        p:maxRefreshDelay="%{openidvci.issuerMetadata.maxRefreshDelay:PT4H}"
+        c:metadata="#{getObject('openidvci.CredentialIssuerMetadata') ?: getObject('openidvci.IssuerMetadataSkeleton')}"
+        p:dynamicValueResolvers-ref="#{'%{openidvci.issuerMetadata.resolver.values:openidvci.issuerMetadata.DefaultDynamicValueResolvers}'.trim()}"
+        p:velocityEngine="#{%{openidvci.issuerMetadata.templated:true} ? getObject('shibboleth.VelocityEngine') : null}"
+        p:issuer="#{getObject('shibboleth.oidc.issuer')}"
+        p:templateContext="#{getObject('openidvci.issuerMetadata.TemplateContext')}" />
+
+    <bean id="openidvci.IssuerMetadataSkeleton" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
+        c:path="%{openidvci.issuerMetadata.template:%{idp.home}/static/openid-credential-issuer.json}" />
+
+    <!--
+    Members of the published document resolved outside the metadata file, keyed by the
+    member each resolver serves. A resolved value replaces the one in the file, so the file
+    does not have to carry the member at all.
+    -->
+    <bean id="openidvci.issuerMetadata.DefaultDynamicValueResolvers" lazy-init="true"
+        class="org.springframework.beans.factory.config.MapFactoryBean">
+        <property name="sourceMap">
+            <map>
+                <entry key="credential_configurations_supported"
+                    value-ref="openidvci.issuerMetadata.CredentialConfigurationsResolver" />
+            </map>
+        </property>
+    </bean>
+
+    <!--
+    Credential Configurations are published from the same file that instructs how a
+    credential is formed, so that what is advertised is what can be issued. This only
+    adapts the resolver below to a metadata member.
+    -->
+    <bean id="openidvci.issuerMetadata.CredentialConfigurationsResolver" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.CredentialConfigurationsValueResolver"
+        depends-on="shibboleth.LoggingService"
+        p:id="openidvci.issuerMetadata.CredentialConfigurationsResolver"
+        c:credentialConfigurations-ref="openidvci.CredentialConfigurationsResolver" />
+
+    <!--
+    Credential Configurations of this deployment. Every flow reading them shares this, the
+    file is read when it changes rather than once per request.
+    -->
+    <bean id="openidvci.CredentialConfigurationsResolver" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver"
+        depends-on="shibboleth.LoggingService"
+        p:id="openidvci.CredentialConfigurationsResolver"
+        p:minRefreshDelay="%{openidvci.credentials.minRefreshDelay:PT5M}"
+        p:maxRefreshDelay="%{openidvci.credentials.maxRefreshDelay:PT4H}"
+        c:credentials-ref="openidvci.CredentialsResource" />
+
+    <bean id="openidvci.CredentialsResource" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
+        c:path="%{openidvci.credentials:%{idp.home}/metadata/verifiable-credentials.json}" />
+
 </beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
index ae555cf..f6af2fe 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml
@@ -24,7 +24,7 @@
     scope="prototype"/>
     
  <bean id="ValidateCredentialOffering" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateCredentialOffering"
-     scope="prototype" p:credentialsResource="%{idp.home}/metadata/verifiable-credentials.json"/>
+     scope="prototype" p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver"/>
      
  <bean id="BuildCredentialOfferToken" class="org.geant.shibboleth.plugin.openidvci.profile.impl.BuildCredentialOfferToken"
     p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index 80a9570..d4cb317 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -32,7 +32,7 @@
   
   <bean id="ValidateRequestedCredential"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.ValidateRequestedCredential"
-        p:credentialsResource="%{idp.home}/metadata/verifiable-credentials.json" scope="prototype" />
+        p:credentialConfigurationsResolver-ref="openidvci.CredentialConfigurationsResolver" scope="prototype" />
         
   <bean id="ParseProof"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.ParseProof" scope="prototype" />
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml
new file mode 100644
index 0000000..3d5fc83
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="shibboleth.openidvci.profileId" class="java.lang.String"
+        c:_0="http://geant.org/ns/profiles/openid/vci/issuer-metadata"/>
+
+  <bean id="InitializeUnverifiedRelyingPartyContext"
+        class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundIssuerMetadataResponseMessage"
+        scope="prototype"
+        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
+
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+        scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml
new file mode 100644
index 0000000..850637d
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-flow.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api">
+
+  <!--
+  Publishes Credential Issuer metadata. There is no inbound message to decode and no
+  client to authenticate, a wallet reads this before it knows anything about us. So the
+  relying party is the unverified one, same as with the status list endpoint.
+  -->
+  <action-state id="InitializeMandatoryContexts">
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
+    <evaluate expression="SelectRelyingPartyConfiguration" />
+    <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="BuildResponseMessage" />
+  </action-state>
+
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/issuer-metadata/issuer-metadata-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 7ef642a..38b07a9 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -48,6 +48,14 @@
     <bean id="OpenID.VCI.Credentials" parent="AbstractVCIProfile" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCICredentialsConfiguration" />
 
+    <!--
+    Endpoint publishing Credential Issuer metadata. Serves wallets before there is a
+    client, so there is nothing of the VCI profile settings to inherit here.
+    -->
+    <bean id="OpenID.VCI.IssuerMetadata" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCIIssuerMetadataConfiguration"
+        p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
     <bean id="OAuth.StatusList" lazy-init="true"
         class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListConfiguration"
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
index 1f72c13..11c82b3 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
@@ -10,24 +10,54 @@
     default-init-method="initialize" default-destroy-method="destroy">
 
     <!--
-    This file defines with which key issued Verifiable Credentials are signed. This file
-    should be imported into credentials.xml.
+    Signing keys of this plugin. Nothing here is active by default: Credentials are signed
+    with the OP's own response signing credentials, the ones in
+    shibboleth.oidc.SigningCredentials, and keeping it that way needs nothing to be done.
+    Uncomment below whichever function you want to give a key of its own.
 
-    Nothing here is active by default. Credentials are signed with OP's own response
-    signing credentials, the ones in shibboleth.oidc.SigningCredentials. Keeping it that
-    way needs nothing to be done.
+    Two functions of this plugin sign, and each of them can have its own key:
 
-    Uncomment beans below to sign Credentials with own key instead. Note that Credential
-    is verified long after issuing it, by wallet or verifier having no relation to your
-    OP. Own key is not private matter between your deployment and its clients. See
-    "Publishing" below.
+      Credential signing        this file, openidvci.SigningCredentials
+        Read by a wallet or verifier that has no relation to your deployment, long after
+        issuing. Needs publishing if separated, see "Publishing" below.
+
+      Status List Token signing this file, oauth.statuslist.SigningCredentials
+        Read by the same verifiers. Constrained, see the section below.
+
+    The OP's own response signing credentials, shibboleth.oidc.SigningCredentials in
+    conf/oidc-credentials.xml, are what both fall back to.
+
+    The defaults share one key across these because a plugin cannot generate keys for you.
+    Separating them narrows what rotating or losing a key affects, which is the reason to
+    bother. Generate each one with bin/jwtgen.sh, the same way as the OP's own keys.
+    -->
+
+    <!--
+    Import this file into conf/credentials.xml.
+
+    Import it into conf/global.xml instead if you state this deployment in OpenID
+    Federation, see conf/openid-vci-oidfed.xml. Credential signing reads the list either
+    way, publishing it in an Entity Configuration only works from the root context. The two
+    go together: the federation file alone refuses to start without the list.
+
+    Import it in ONE place, not both. The relying party service is a child of the root
+    context and reads beans from it, so everything signing Credentials still finds the list
+    when it is declared in conf/global.xml. Importing this file in both places declares the
+    same bean ids twice, and the child copies shadow the root ones: Credential signing would
+    then use one set of credential objects and the Entity Configuration another. Reloading
+    conf/credentials.xml could move signing to a new key while vc_issuer still states the old
+    one, which is the drift this arrangement exists to prevent.
+
+    The cost of moving the import is reload. conf/credentials.xml belongs to the relying party
+    service, which is reloadable, so a key listed there can be changed without a restart.
+    conf/global.xml is the root context and is not reloadable. Federating therefore makes key
+    changes a restart, which is why the import location is left as credentials.xml by default.
     -->
 
     <!--
     Signing key of Verifiable Credentials only. Must be EC key, signing actions support
-    nothing else, and it should match one of algorithms you advertise in
-    credential_signing_alg_values_supported of your Credential Issuer metadata. Generate
-    one with bin/jwtgen.sh, same way as OP's own keys.
+    nothing else, and it should match one of the algorithms you advertise in
+    credential_signing_alg_values_supported of your Credential Issuer metadata.
     -->
     <!--
     <bean id="openidvci.DefaultESSigningCredential" parent="shibboleth.JWKCredential"
@@ -36,11 +66,11 @@
 
     <!--
     Lists ALL credentials used for signing issued Verifiable Credentials. Defining this
-    list REPLACES OP's credentials in Credential signing, it does not add to them. So list
-    every key you want to be considered. First one compatible with used algorithm is the
-    one signing.
+    list REPLACES the OP's credentials in Credential signing, it does not add to them. So
+    list every key you want considered. The first one compatible with the algorithm in use
+    is the one signing.
 
-    Leave this undefined to keep sharing OP's signing credentials.
+    Leave this undefined to keep sharing the OP's signing credentials.
     -->
     <!--
     <util:list id="openidvci.SigningCredentials">
@@ -49,32 +79,39 @@
     -->
 
     <!--
-    Status List Tokens are signed separately from Credentials. Status list is general
-    purpose mechanism and does not serve only Verifiable Credentials. By default they are
-    signed with same key as Credentials, so nothing needs to be done here.
-
-    Define this list ONLY if status list must be signed with own key. Status List Token
-    names same issuer as Credentials referring to it. While that issuer is did:jwk, issuer
-    IS the key: separating them makes your Credentials refer to status list that looks like
-    issued by somebody else. Separate them only when openidvci.issuer names real issuer.
+    Signing key of Status List Tokens. A status list is a general purpose mechanism and does
+    not serve only Verifiable Credentials, so it has its own key where it can.
+
+    IT CANNOT WHILE THE ISSUER IS A did:jwk, which is the default. A Status List Token names
+    the same issuer as the Credentials referring to it, and while that issuer is a did:jwk
+    the issuer IS the signing key: a separate key would make your Credentials point at a
+    status list that reads as issued by somebody else. So uncomment these two only once
+    openidvci.issuer in conf/openid-vci.properties names a real issuer. Until then the
+    Credential signing key above signs Status List Tokens too.
     -->
     <!--
+    <bean id="oauth.statuslist.DefaultESSigningCredential" parent="shibboleth.JWKCredential"
+        p:resource="%{idp.signing.oauth.statuslist.es.key}" />
+
     <util:list id="oauth.statuslist.SigningCredentials">
-        <ref bean="openidvci.DefaultESSigningCredential" />
+        <ref bean="oauth.statuslist.DefaultESSigningCredential" />
     </util:list>
     -->
 
     <!--
     Publishing.
 
-    Wallet or verifier finds your public key from key set OP publishes in its jwks_uri.
-    That key set has shibboleth.oidc.SigningCredentials in it, so key defined above is NOT
-    published. Credentials signed with it CANNOT be verified before you say otherwise.
+    A wallet or verifier finds your public key from the key set the OP publishes in its
+    jwks_uri. That key set holds shibboleth.oidc.SigningCredentials, so the key defined
+    above is NOT published. Credentials signed with it CANNOT be verified before you say
+    otherwise.
 
-    To publish it, define shibboleth.oidc.SigningCredentialsToPublish in oidc-credentials.xml.
-    That bean REPLACES published set also, it does not add to it. So it must list OP's own
-    signing credentials too, or clients can not verify id tokens anymore. List looks like
-    this:
+    This matters only if you separated the key above. Publishing it in an OpenID Federation
+    Entity Configuration is one way, see
+    conf/openid-vci-oidfed.xml. Publishing it in the OP's key set is the other: define
+    shibboleth.oidc.SigningCredentialsToPublish in oidc-credentials.xml. That bean REPLACES
+    the published set, it does not add to it, so it must list the OP's own signing
+    credentials too or clients can no longer verify id tokens. The list looks like this:
 
     <util:list id="shibboleth.oidc.SigningCredentialsToPublish">
         <ref bean="shibboleth.oidc.DefaultRSSigningCredential" />
@@ -82,12 +119,13 @@
         <ref bean="openidvci.DefaultESSigningCredential" />
     </util:list>
 
-    Note that same list is also set of keys trusted in validation of JWTs your deployment
-    has issued to itself, logout tokens for example. Credential signing key becomes trusted
-    there too.
+    Note that the same list is also the set of keys trusted when validating JWTs your
+    deployment has issued to itself, logout tokens for example. The Credential signing key
+    becomes trusted there too. Publishing through the federation avoids that, and is why
+    ...ToPublish is the second choice rather than the first.
 
-    Publish the key before you start signing with it. Verifier having fetched key set
-    earlier may still have old one in cache.
+    Publish the key before you start signing with it. A verifier that fetched the key set
+    earlier may still have the old one cached.
     -->
 
 </beans>
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
new file mode 100644
index 0000000..a7fd47e
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <!--
+    This file states in OpenID Federation that this deployment is a Credential Issuer. It
+    should be imported into conf/global.xml.
+
+    IMPORT IT ONLY IF THE OpenID Federation PLUGIN IS INSTALLED. The bean below implements
+    an interface that ships with that plugin, so without it the bean cannot be created and
+    the IdP will not start.
+
+    Nothing here is active by default, importing the file is what turns it on. Once
+    imported, the federation module picks the bean up on its own, there is no list of
+    decorators to add it to. It collects every bean of the decorator type it can see.
+
+    Three entity types are stated:
+
+    openid_credential_issuer
+        The Credential Issuer metadata, the same document /.well-known/openid-credential-issuer
+        serves. It comes from the same resolver, so the two cannot drift apart. A member
+        wanted only in the federation, "authorization_endpoint" for example, is added to
+        static/openid-credential-issuer.json and needs nothing here.
+
+    vc_issuer
+        A JWK Set of the keys Credentials are signed with, public halves only. This is how
+        a verifier finds the key: it reads a Credential long after issuing, and has no
+        relation to this deployment.
+
+        The keys are listed below, and the list has to be here rather than resolved from the
+        request. An entity configuration describes the entity, and the federation module
+        caches one document per deployment, so nothing in it may depend on a relying party.
+
+    federation_entity
+        display_name and logo_uri, taken from the display properties of the Credential
+        Issuer metadata. Only members the federation module's own entity configuration
+        metadata does not already carry are filled. Set decorateFederationEntity to false
+        to state federation_entity entirely in that file instead.
+    -->
+
+    <!--
+    The keys stated in vc_issuer are read from bean "openidvci.SigningCredentials", the same
+    bean the Credential signing configuration reads. One declaration, so what is published
+    is what signs.
+
+    THAT BEAN HAS TO BE DECLARED WHERE THE ROOT CONTEXT READS IT, which means importing
+    conf/openid-vci-credentials.xml into conf/global.xml rather than into conf/credentials.xml.
+    Credential signing lives in the reloadable relying party service, a child of the root
+    context, so it still finds the bean; the other way around does not work, which is why
+    this file cannot simply point at openidvci.SigningConfiguration. The plugin refuses to
+    start if the bean is not there: a Credential Issuer stating no key in a federation is a
+    deployment error, not something to find out from a verifier that cannot read a Credential.
+
+    The plugin's default, sharing the OP's response signing credentials, cannot be read here
+    directly: bean shibboleth.oidc.SigningCredentialsFactory lives in the relying party
+    service. The keys behind it can be, though. They are JWK files named by properties, so
+    declaring the list against the same property keeps one source:
+
+    <util:list id="openidvci.SigningCredentials">
+        <bean parent="shibboleth.JWKCredential" p:resource="%{idp.signing.oidc.es.key}" />
+    </util:list>
+
+    List the EC key only. Credentials are signed with EC algorithms and nothing else, so an
+    RSA key in this list would be stated in vc_issuer as a key that signs Credentials while
+    never signing one. Note also that this pins the key: rotating the OP's key by pointing
+    conf/oidc-credentials.xml at another resource would leave this list behind.
+
+    Preferring a key of your own is still the better answer. A key published in a federation
+    as a Credential signing key should sign Credentials and nothing else, so generate one
+    with bin/jwtgen.sh and enable it in conf/openid-vci-credentials.xml.
+    -->
+    <bean id="openidvci.oidfed.CredentialIssuerEntityConfigurationMetadataDecorator"
+        class="org.geant.shibboleth.plugin.openidvci.oidfed.impl.CredentialIssuerEntityConfigurationMetadataDecorator"
+        p:id="openidvci.oidfed.CredentialIssuerEntityConfigurationMetadataDecorator"
+        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}"
+        p:credentialSigningCredentials="#{getObject('openidvci.SigningCredentials')}"
+        p:decorateFederationEntity="%{openidvci.oidfed.decorateFederationEntity:true}" />
+
+    <!--
+    The entity statement itself is signed with the federation module's own key, not with the
+    keys above. Section 5.2.1 of OpenID Federation names them as distinct: the federation
+    key identifies the entity to the federation, the keys above let a verifier read a
+    Credential. Keep them separate.
+
+    Worth checking once, because the federation module signs with
+    shibboleth.oidfed.SigningCredentials and falls back to shibboleth.oidc.SigningCredentials
+    when that is undefined. A Credential signing key that is also in the OP's list therefore
+    ends up trusted to sign entity statements. It shows in the published statement: the top
+    level "jwks" claim is the Federation Entity Keys, and a Credential signing key belongs in
+    metadata.vc_issuer.jwks only. Declaring shibboleth.oidfed.SigningCredentials explicitly
+    separates them.
+    -->
+
+</beans>
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 fe0350a..6b93c88 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -1,9 +1,38 @@
 oauth.statuslist.indexPermutationKey=change_this_to_secret
 oauth.statuslist.ttl=PT1H
 oauth.statuslist.assignmentRetention=P7D
+#Openid Issuer in credentials. Default to did:jwk
 openidvci.issuer=
 
-# Signing key of issued Verifiable Credentials. Used only if you enable credential of same
-# name in conf/openid-vci-credentials.xml. Until that, Credentials are signed with OP's own
-# signing credentials. Must be EC key.
+# Signing key of issued Verifiable Credentials. Used only if you enable the credential of the
+# same name in conf/openid-vci-credentials.xml. Until that, Credentials are signed with the
+# OP's own signing credentials. Must be EC key.
 idp.signing.openidvci.es.key = %{idp.home}/credentials/openid-vci-signing-es.jwk
+
+# Signing key of Status List Tokens. Used only if you enable the credential of the same name
+# in conf/openid-vci-credentials.xml. Must be EC key.
+idp.signing.oauth.statuslist.es.key = %{idp.home}/credentials/oauth-statuslist-signing-es.jwk
+
+# 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.
+#openidvci.credentials = %{idp.home}/metadata/verifiable-credentials.json
+#openidvci.credentials.minRefreshDelay = PT5M
+#openidvci.credentials.maxRefreshDelay = PT4H
+
+# Settings for the Credential Issuer metadata flow
+# Template for metadata
+#openidvci.issuerMetadata.template = %{idp.home}/static/openid-credential-issuer.json
+# The template is Velocity, its variables are $issuer, $baseUrl and $host, derived from the
+# issuer of the OP. 
+#openidvci.issuerMetadata.templated = true
+#openidvci.issuerMetadata.resolver = openidvci.DefaultCredentialIssuerMetadataResolver
+#openidvci.issuerMetadata.resolver.values = openidvci.issuerMetadata.DefaultDynamicValueResolvers
+#openidvci.issuerMetadata.minRefreshDelay = PT5M
+#openidvci.issuerMetadata.maxRefreshDelay = PT4H
+
+# Settings for stating this deployment as a Credential Issuer in OpenID Federation. Active
+# only when conf/openid-vci-oidfed.xml is imported into conf/global.xml. Set to false to
+# state federation_entity entirely in the federation module's own metadata file instead of
+# filling display_name and logo_uri from Credential Issuer metadata.
+#openidvci.oidfed.decorateFederationEntity = true
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
index f65119b..8608bf1 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
@@ -16,6 +16,20 @@ OpenID-VCI.1.dest = conf/openid-vci.properties
 OpenID-VCI.2.src = /org/geant/shibboleth/plugin/openidvci/conf/openid-vci-credentials.xml
 OpenID-VCI.2.dest = conf/openid-vci-credentials.xml
 
+OpenID-VCI.3.src = /org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
+OpenID-VCI.3.dest = static/openid-credential-issuer.json
+
+OpenID-VCI.4.src = /org/geant/shibboleth/plugin/openidvci/conf/openid-vci-oidfed.xml
+OpenID-VCI.4.dest = conf/openid-vci-oidfed.xml
+
 OpenID-VCI.postenable = Add <import resource="openid-vci-credentials.xml" /> in conf/credentials.xml \
-if you want to sign Verifiable Credentials with own key. Until that, Credentials are signed with \
-OP's own signing credentials.
+if you want to sign Verifiable Credentials, or Status List Tokens, with keys of their own. Until \
+that, Credentials are signed with OP's own signing credentials. That file lists every signing \
+function of this plugin and the key each one can be given. \
+Wire /.well-known/openid-credential-issuer to /idp/profile/openid/vci/issuer-metadata to publish \
+Credential Issuer metadata. Urls in static/openid-credential-issuer.json name your host already, \
+it is a Velocity template evaluated against the issuer of the OP. Credential Configurations are not \
+kept in that file, they are published from metadata/verifiable-credentials.json. \
+If you run the OpenID Federation plugin, add <import resource="openid-vci-oidfed.xml" /> in \
+conf/global.xml to state this deployment as a Credential Issuer in its Entity Configuration. Do \
+not import it without that plugin, the IdP will not start.
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
new file mode 100644
index 0000000..12d7144
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/openid-credential-issuer.json
@@ -0,0 +1,14 @@
+{
+   "credential_issuer":"$baseUrl",
+   "authorization_servers":[
+      "$issuer"
+   ],
+   "credential_endpoint":"$baseUrl/idp/profile/openid/vci/credentials",
+   "nonce_endpoint":"$baseUrl/idp/profile/openid/vci/nonce",
+   "display":[
+      {
+         "name":"$host",
+         "locale":"en-US"
+      }
+   ]
+}
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
new file mode 100644
index 0000000..c11b16c
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/FilesystemCredentialConfigurationsResolverTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.impl;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfigurations;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemCredentialConfigurationsResolver}.
+ */
+public class FilesystemCredentialConfigurationsResolverTest {
+
+    private FilesystemCredentialConfigurationsResolver resolverOf(final String path) throws Exception {
+        final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+                new ClassPathResource(path));
+        resolver.setId("test");
+        resolver.initialize();
+        return resolver;
+    }
+
+    @Test
+    public void testSuccess() throws Exception {
+        final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+                .resolveSingle(null);
+
+        Assert.assertNotNull(configurations);
+        Assert.assertEquals(configurations.getCredentialConfigurations().keySet(),
+                Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+        Assert.assertEquals(configurations.getSupported().keySet(),
+                Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+    }
+
+    @Test
+    public void testParsedAndSourceAgree() throws Exception {
+        final CredentialConfigurations configurations = resolverOf("conf/verifiable-credentials.json")
+                .resolveSingle(null);
+
+        Assert.assertNotNull(configurations);
+        Assert.assertEquals(configurations.getCredentialConfigurations().get("GeantIncubatorDiploma").getFormat(),
+                "dc+sd-jwt");
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> source = (Map<String, Object>) configurations.getSupported()
+                .get("GeantIncubatorDiploma");
+        Assert.assertEquals(source.get("format"), "dc+sd-jwt");
+        Assert.assertEquals(source.get("vct"), "GeantIncubatorDiploma");
+        Assert.assertTrue(source.containsKey("credential_metadata"));
+    }
+
+    @Test
+    public void testTheSameInstanceIsServedUntilTheFileChanges() throws Exception {
+        final FilesystemCredentialConfigurationsResolver resolver = resolverOf("conf/verifiable-credentials.json");
+
+        final CredentialConfigurations first = resolver.resolveSingle(null);
+        resolver.refresh();
+        final CredentialConfigurations second = resolver.resolveSingle(null);
+
+        Assert.assertSame(first, second);
+    }
+
+    @Test
+    public void testNoCredentialConfigurations() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("conf/no-credential-configurations.json"));
+    }
+
+    @Test
+    public void testUnparsableCredentialConfiguration() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("conf/unparsable-credential-configuration.json"));
+    }
+
+    @Test
+    public void testUnparsableFile() {
+        Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf("conf/unparsable.txt"));
+    }
+
+}
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
new file mode 100644
index 0000000..39adbb9
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/CredentialConfigurationsValueResolverTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.metadata.impl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+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;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link CredentialConfigurationsValueResolver}.
+ */
+public class CredentialConfigurationsValueResolverTest {
+
+    private CredentialConfigurationsValueResolver adapterOf(final String path) throws Exception {
+        final FilesystemCredentialConfigurationsResolver delegate = new FilesystemCredentialConfigurationsResolver(
+                new ClassPathResource(path));
+        delegate.setId("delegate");
+        delegate.initialize();
+
+        final CredentialConfigurationsValueResolver adapter = new CredentialConfigurationsValueResolver(delegate);
+        adapter.setId("test");
+        adapter.initialize();
+        return adapter;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testSuccess() throws Exception {
+        final Object resolved = adapterOf("conf/verifiable-credentials.json").resolveSingle(null);
+
+        Assert.assertTrue(resolved instanceof Map);
+        Assert.assertEquals(((Map<String, Object>) resolved).keySet(),
+                Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+    }
+
+    @Test
+    public void testConfigurationIsPublishedAsWritten() throws Exception {
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> resolved = (Map<String, Object>) adapterOf("conf/verifiable-credentials.json")
+                .resolveSingle(null);
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> configuration = (Map<String, Object>) resolved.get("GeantIncubatorDiploma");
+
+        Assert.assertEquals(configuration.get("format"), "dc+sd-jwt");
+        Assert.assertEquals(configuration.get("vct"), "GeantIncubatorDiploma");
+        Assert.assertTrue(configuration.containsKey("credential_metadata"));
+    }
+
+    @Test
+    public void testRefreshIsDelegated() throws Exception {
+        final CredentialConfigurationsValueResolver adapter = adapterOf("conf/verifiable-credentials.json");
+
+        adapter.refresh();
+
+        Assert.assertNotNull(adapter.getLastRefresh());
+        Assert.assertNotNull(adapter.getLastUpdate());
+    }
+
+    @Test
+    public void testNothingToPublish() throws Exception {
+        final CredentialConfigurationsValueResolver adapter = new CredentialConfigurationsValueResolver(
+                new EmptyResolver());
+        adapter.setId("test");
+        adapter.initialize();
+
+        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());
+    }
+
+    /** Resolver that has nothing to resolve, and is not refreshable. */
+    private static class EmptyResolver implements CredentialConfigurationsResolver {
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterable<CredentialConfigurations> resolve(final ProfileRequestContext criteria) {
+            return List.of();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public CredentialConfigurations resolveSingle(final ProfileRequestContext criteria) {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String getId() {
+            return "empty";
+        }
+
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java
new file mode 100644
index 0000000..272ab42
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/DynamicFilesystemCredentialIssuerMetadataResolverTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.metadata.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataValueResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link DynamicFilesystemCredentialIssuerMetadataResolver}.
+ */
+public class DynamicFilesystemCredentialIssuerMetadataResolverTest {
+
+    private DynamicFilesystemCredentialIssuerMetadataResolver resolverOf(final String path,
+            final Map<String, ? extends CredentialIssuerMetadataValueResolver> dynamic) throws Exception {
+        final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+                new ClassPathResource(path));
+        resolver.setId("test");
+        if (dynamic != null) {
+            resolver.setDynamicValueResolvers(dynamic);
+        }
+        resolver.initialize();
+        return resolver;
+    }
+
+    private DynamicFilesystemCredentialIssuerMetadataResolver templatedResolverOf(final String path,
+            final String issuer) throws Exception {
+        final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+                new ClassPathResource(path));
+        resolver.setId("test");
+        resolver.setVelocityEngine(velocityEngine());
+        resolver.setIssuer(issuer);
+        resolver.initialize();
+        return resolver;
+    }
+
+    private VelocityEngine velocityEngine() {
+        final VelocityEngine engine = new VelocityEngine();
+        engine.setProperty("resource.loaders", "string");
+        engine.setProperty("resource.loader.string.class", StringResourceLoader.class.getName());
+        engine.init();
+        return engine;
+    }
+
+    private CredentialConfigurationsValueResolver credentialConfigurations() throws Exception {
+        final FilesystemCredentialConfigurationsResolver delegate = new FilesystemCredentialConfigurationsResolver(
+                new ClassPathResource("conf/verifiable-credentials.json"));
+        delegate.setId("credentialConfigurationsDelegate");
+        delegate.initialize();
+
+        final CredentialConfigurationsValueResolver resolver = new CredentialConfigurationsValueResolver(delegate);
+        resolver.setId("credentialConfigurations");
+        resolver.initialize();
+        return resolver;
+    }
+
+    @Test
+    public void testFileOnly() throws Exception {
+        final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json", null)
+                .resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+        Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+                "https://example.org/idp/profile/openid/vci/credentials");
+        Assert.assertFalse(metadata.hasMember(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testCredentialConfigurationsAreOverlaid() throws Exception {
+        final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json",
+                Map.of(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, credentialConfigurations()))
+                .resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        final Object configurations = metadata.getMembers()
+                .get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+        Assert.assertTrue(configurations instanceof Map);
+        Assert.assertEquals(((Map<String, Object>) configurations).size(), 2);
+
+        final List<String> members = new ArrayList<>(metadata.getMembers().keySet());
+        Assert.assertEquals(members.subList(0, 5), List.of(CredentialIssuerMetadata.CREDENTIAL_ISSUER,
+                "authorization_servers", CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "nonce_endpoint", "display"));
+        Assert.assertTrue(members.contains(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED));
+    }
+
+    @Test
+    public void testOverlayReplacesTheFileValue() throws Exception {
+        final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer.json",
+                Map.of("nonce_endpoint", new ConstantValueResolver("https://overlaid.example.org/nonce")))
+                .resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getMembers().get("nonce_endpoint"), "https://overlaid.example.org/nonce");
+    }
+
+    @Test
+    public void testNoCredentialIssuerInFile() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("metadata/openid-credential-issuer-no-issuer.json", null));
+    }
+
+    @Test
+    public void testTemplateNamesTheHost() throws Exception {
+        final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+                "https://example.org").resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+        Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+                "https://example.org/idp/profile/openid/vci/credentials");
+        Assert.assertEquals(metadata.getMembers().get("nonce_endpoint"),
+                "https://example.org/idp/profile/openid/vci/nonce");
+        Assert.assertEquals(metadata.getMembers().get("authorization_servers"), List.of("https://example.org"));
+    }
+
+    @Test
+    public void testBaseUrlDropsThePathOfTheIssuer() throws Exception {
+        final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+                "https://example.org/idp/shibboleth").resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org");
+        Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+                "https://example.org/idp/profile/openid/vci/credentials");
+        Assert.assertEquals(metadata.getMembers().get("authorization_servers"),
+                List.of("https://example.org/idp/shibboleth"));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testHostVariable() throws Exception {
+        final CredentialIssuerMetadata metadata = templatedResolverOf("metadata/openid-credential-issuer-template.json",
+                "https://example.org:8443").resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://example.org:8443");
+        final List<Object> display = (List<Object>) metadata.getMembers().get("display");
+        Assert.assertEquals(((Map<String, Object>) display.get(0)).get("name"), "example.org");
+    }
+
+    @Test
+    public void testTemplateContextReplacesADerivedVariable() throws Exception {
+        final DynamicFilesystemCredentialIssuerMetadataResolver resolver = new DynamicFilesystemCredentialIssuerMetadataResolver(
+                new ClassPathResource("metadata/openid-credential-issuer-template.json"));
+        resolver.setId("test");
+        resolver.setVelocityEngine(velocityEngine());
+        resolver.setIssuer("https://example.org");
+        resolver.setTemplateContext(Map.of("baseUrl", "https://vci.example.org"));
+        resolver.initialize();
+
+        final CredentialIssuerMetadata metadata = resolver.resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "https://vci.example.org");
+        Assert.assertEquals(metadata.getMembers().get("authorization_servers"), List.of("https://example.org"));
+    }
+
+    @Test
+    public void testTemplateIsNotEvaluatedWithoutAnEngine() throws Exception {
+        final CredentialIssuerMetadata metadata = resolverOf("metadata/openid-credential-issuer-template.json", null)
+                .resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getCredentialIssuer().getValue(), "$baseUrl");
+    }
+
+    @Test
+    public void testUnresolvedReferenceIsLeftInPlace() throws Exception {
+        final CredentialIssuerMetadata metadata = templatedResolverOf(
+                "metadata/openid-credential-issuer-unresolved.json", "https://example.org").resolveSingle(null);
+
+        Assert.assertNotNull(metadata);
+        Assert.assertEquals(metadata.getMembers().get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+                "$notAVariable/credentials");
+    }
+
+    @Test
+    public void testIssuerNamingNoHost() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> templatedResolverOf("metadata/openid-credential-issuer-template.json", "not-a-url"));
+    }
+
+    /** Resolver serving a fixed value, standing in for a deployer supplied one. */
+    private static class ConstantValueResolver implements CredentialIssuerMetadataValueResolver {
+
+        /** Value to serve. */
+        private final Object value;
+
+        /**
+         * Constructor.
+         *
+         * @param constant value to serve
+         */
+        ConstantValueResolver(final Object constant) {
+            value = constant;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterable<Object> resolve(final ProfileRequestContext criteria) {
+            return List.of(value);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Object resolveSingle(final ProfileRequestContext criteria) {
+            return value;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String getId() {
+            return "constant";
+        }
+
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java
new file mode 100644
index 0000000..31837e6
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/oidfed/impl/CredentialIssuerEntityConfigurationMetadataDecoratorTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.oidfed.impl;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.openidvci.credential.impl.FilesystemCredentialConfigurationsResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.impl.CredentialConfigurationsValueResolver;
+import org.geant.shibboleth.plugin.openidvci.metadata.impl.DynamicFilesystemCredentialIssuerMetadataResolver;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.springframework.core.io.ClassPathResource;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+
+/**
+ * Unit tests for {@link CredentialIssuerEntityConfigurationMetadataDecorator}.
+ */
+public class CredentialIssuerEntityConfigurationMetadataDecoratorTest {
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private ECKey signingKey;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        signingKey = new ECKeyGenerator(Curve.P_256).keyID("credential-signing")
+                .algorithm(JWSAlgorithm.ES256).keyUse(KeyUse.SIGNATURE).generate();
+
+        // Nothing of the request feeds the document, so a bare context is enough.
+        profileRequestCtx = new ProfileRequestContext();
+    }
+
+    /**
+     * Build an initialized decorator.
+     *
+     * @param federationEntity whether federation_entity is filled
+     * @return initialized decorator
+     * @throws Exception if the decorator cannot be built
+     */
+    private CredentialIssuerEntityConfigurationMetadataDecorator decoratorOf(final boolean federationEntity)
+            throws Exception {
+        final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+                new CredentialIssuerEntityConfigurationMetadataDecorator();
+        decorator.setId("test");
+        decorator.setMetadataResolver(metadataResolver());
+        decorator.setCredentialSigningCredentials(
+                List.of(CredentialConversionUtil.keyToCredential(signingKey)));
+        decorator.setDecorateFederationEntity(federationEntity);
+        decorator.initialize();
+        return decorator;
+    }
+
+    /**
+     * Build the resolver of the metadata to state, with Credential Configurations
+     * overlaid the way the published document has them.
+     *
+     * @return initialized resolver
+     * @throws Exception if the resolver cannot be built
+     */
+    private DynamicFilesystemCredentialIssuerMetadataResolver metadataResolver() throws Exception {
+        final FilesystemCredentialConfigurationsResolver configurations =
+                new FilesystemCredentialConfigurationsResolver(
+                        new ClassPathResource("conf/verifiable-credentials.json"));
+        configurations.setId("credentialConfigurations");
+        configurations.initialize();
+
+        final CredentialConfigurationsValueResolver adapter =
+                new CredentialConfigurationsValueResolver(configurations);
+        adapter.setId("credentialConfigurationsAdapter");
+        adapter.initialize();
+
+        final DynamicFilesystemCredentialIssuerMetadataResolver resolver =
+                new DynamicFilesystemCredentialIssuerMetadataResolver(
+                        new ClassPathResource("metadata/openid-credential-issuer.json"));
+        resolver.setId("metadata");
+        resolver.setDynamicValueResolvers(
+                Map.of(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED, adapter));
+        resolver.initialize();
+        return resolver;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testCredentialIssuerEntityType() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        final Map<String, Object> stated =
+                metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER);
+        Assert.assertNotNull(stated);
+        Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+        Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT),
+                "https://example.org/idp/profile/openid/vci/credentials");
+        Assert.assertEquals(stated.get("authorization_servers"), List.of("https://example.org"));
+
+        // The same Credential Configurations the well known endpoint serves.
+        final Map<String, Object> configurations = (Map<String, Object>) stated
+                .get(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+        Assert.assertEquals(configurations.keySet(),
+                Set.of("GeantIncubatorDiploma", "GeantIncubatorDiploma2"));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testVcIssuerEntityTypeCarriesAJwkSet() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        final Map<String, Object> stated =
+                metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER);
+        Assert.assertNotNull(stated);
+
+        // A JWK Set object, not a bare array, which is what the federation module parses.
+        final Map<String, Object> jwks =
+                (Map<String, Object>) stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.JWKS);
+        Assert.assertNotNull(jwks);
+        final List<Object> keys =
+                (List<Object>) jwks.get(CredentialIssuerEntityConfigurationMetadataDecorator.KEYS);
+        Assert.assertEquals(keys.size(), 1);
+
+        final Map<String, Object> key = (Map<String, Object>) keys.get(0);
+        Assert.assertEquals(key.get("kty"), "EC");
+        Assert.assertEquals(key.get("crv"), "P-256");
+        Assert.assertEquals(key.get("kid"), "credential-signing");
+        Assert.assertEquals(key.get("alg"), "ES256");
+        Assert.assertEquals(key.get("use"), "sig");
+        Assert.assertNotNull(key.get("x"));
+        Assert.assertNotNull(key.get("y"));
+    }
+
+    @Test
+    public void testPrivateKeyIsNotStated() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        Assert.assertFalse(metadata.toString().contains(signingKey.getD().toString()),
+                "Private key of the credential signing key must not reach the entity configuration");
+    }
+
+    @Test
+    public void testFederationEntityIsFilledFromDisplay() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        final Map<String, Object> stated =
+                metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY);
+        Assert.assertNotNull(stated);
+        Assert.assertEquals(stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME),
+                "Example Credential Issuer");
+    }
+
+    @Test
+    public void testFederationEntityOfTheDeployerWins() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+        final Map<String, Object> federationEntity = new LinkedHashMap<>();
+        federationEntity.put("organization_name", "Example organization");
+        federationEntity.put(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME, "Set by deployer");
+        metadata.put(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY, federationEntity);
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        final Map<String, Object> stated =
+                metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY);
+        Assert.assertEquals(stated.get(CredentialIssuerEntityConfigurationMetadataDecorator.DISPLAY_NAME),
+                "Set by deployer");
+        Assert.assertEquals(stated.get("organization_name"), "Example organization");
+    }
+
+    @Test
+    public void testFederationEntityIsLeftAloneWhenTurnedOff() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        decoratorOf(false).accept(metadata, profileRequestCtx);
+
+        Assert.assertFalse(
+                metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY));
+        // The other two entity types are still stated.
+        Assert.assertTrue(
+                metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER));
+        Assert.assertTrue(metadata.containsKey(CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER));
+    }
+
+    @Test
+    public void testEntityTypeAlreadyThereIsAddedTo() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+        final Map<String, Object> existing = new LinkedHashMap<>();
+        existing.put("something_of_the_deployer", "kept");
+        metadata.put(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER, existing);
+
+        decoratorOf(true).accept(metadata, profileRequestCtx);
+
+        final Map<String, Object> stated =
+                metadata.get(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER);
+        Assert.assertEquals(stated.get("something_of_the_deployer"), "kept");
+        Assert.assertEquals(stated.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+    }
+
+    @Test
+    public void testNullArguments() throws Exception {
+        final CredentialIssuerEntityConfigurationMetadataDecorator decorator = decoratorOf(true);
+
+        Assert.assertThrows(ConstraintViolationException.class,
+                () -> decorator.accept(null, profileRequestCtx));
+        Assert.assertThrows(ConstraintViolationException.class,
+                () -> decorator.accept(new LinkedHashMap<>(), null));
+    }
+
+    @Test
+    public void testNothingIsReadFromTheRequest() throws Exception {
+        final Map<String, Map<String, Object>> metadata = new LinkedHashMap<>();
+
+        // An entity configuration describes the entity, and the module caches one document
+        // per deployment, so a context carrying nothing must produce the whole statement.
+        decoratorOf(true).accept(metadata, new ProfileRequestContext());
+
+        Assert.assertEquals(metadata.keySet(),
+                Set.of(CredentialIssuerEntityConfigurationMetadataDecorator.OPENID_CREDENTIAL_ISSUER,
+                        CredentialIssuerEntityConfigurationMetadataDecorator.VC_ISSUER,
+                        CredentialIssuerEntityConfigurationMetadataDecorator.FEDERATION_ENTITY));
+    }
+
+    @Test
+    public void testNoCredentialSigningCredentials() throws Exception {
+        final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+                new CredentialIssuerEntityConfigurationMetadataDecorator();
+        decorator.setId("test");
+        decorator.setMetadataResolver(metadataResolver());
+        decorator.setCredentialSigningCredentials(List.of());
+
+        // Publishing in a federation with no key stated is refused at startup.
+        Assert.assertThrows(ComponentInitializationException.class, () -> decorator.initialize());
+    }
+
+    @Test
+    public void testNoMetadataResolver() {
+        final CredentialIssuerEntityConfigurationMetadataDecorator decorator =
+                new CredentialIssuerEntityConfigurationMetadataDecorator();
+        decorator.setId("test");
+
+        Assert.assertThrows(ComponentInitializationException.class, () -> decorator.initialize());
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java
new file mode 100644
index 0000000..0557811
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundIssuerMetadataResponseMessageTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.shared.resolver.ResolverException;
+import org.springframework.webflow.execution.RequestContext;
+
+/**
+ * Unit tests for {@link FormOutboundIssuerMetadataResponseMessage}.
+ */
+public class FormOutboundIssuerMetadataResponseMessageTest {
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private RequestContext requestCtx;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+    }
+
+    private Map<String, Object> members() {
+        final Map<String, Object> members = new LinkedHashMap<>();
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ISSUER, "https://example.org");
+        members.put(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT, "https://example.org/credentials");
+        members.put(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED,
+                Map.of("Diploma", Map.of("format", "dc+sd-jwt")));
+        members.put("display", List.of(Map.of("name", "Example")));
+        return members;
+    }
+
+    private FormOutboundIssuerMetadataResponseMessage actionOf(final CredentialIssuerMetadata metadata)
+            throws Exception {
+        final FormOutboundIssuerMetadataResponseMessage action = new FormOutboundIssuerMetadataResponseMessage();
+        action.setMetadataResolver(new StubResolver(metadata, false));
+        action.initialize();
+        return action;
+    }
+
+    @Test
+    public void testSuccess() throws Exception {
+        final FormOutboundIssuerMetadataResponseMessage action = actionOf(CredentialIssuerMetadata.parse(members()));
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+        Assert.assertTrue(message instanceof CredentialIssuerMetadataSuccessResponse);
+
+        final Map<String, Object> published = new ObjectMapper().readValue(
+                ((CredentialIssuerMetadataSuccessResponse) message).toHTTPResponse().getContent(),
+                new TypeReference<LinkedHashMap<String, Object>>() {
+                });
+        Assert.assertEquals(published.keySet(), members().keySet());
+        Assert.assertEquals(published.get(CredentialIssuerMetadata.CREDENTIAL_ISSUER), "https://example.org");
+    }
+
+    @Test
+    public void testNoMetadata() throws Exception {
+        ActionTestingSupport.assertEvent(actionOf(null).execute(requestCtx), EventIds.IO_ERROR);
+    }
+
+    @Test
+    public void testResolverFails() throws Exception {
+        final FormOutboundIssuerMetadataResponseMessage action = new FormOutboundIssuerMetadataResponseMessage();
+        action.setMetadataResolver(new StubResolver(null, true));
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+    }
+
+    @Test
+    public void testNoCredentialEndpoint() throws Exception {
+        final Map<String, Object> members = members();
+        members.remove(CredentialIssuerMetadata.CREDENTIAL_ENDPOINT);
+
+        ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members)).execute(requestCtx),
+                EventIds.IO_ERROR);
+    }
+
+    @Test
+    public void testNoCredentialConfigurations() throws Exception {
+        final Map<String, Object> members = members();
+        members.remove(CredentialIssuerMetadata.CREDENTIAL_CONFIGURATIONS_SUPPORTED);
+
+        ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members)).execute(requestCtx),
+                EventIds.IO_ERROR);
+    }
+
+    private static class StubResolver implements CredentialIssuerMetadataResolver {
+
+        private final CredentialIssuerMetadata metadata;
+
+        private final boolean fail;
+
+        StubResolver(final CredentialIssuerMetadata document, final boolean failing) {
+            metadata = document;
+            fail = failing;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterable<CredentialIssuerMetadata> resolve(final ProfileRequestContext criteria)
+                throws ResolverException {
+            if (fail) {
+                throw new ResolverException("Testing");
+            }
+            return metadata == null ? List.of() : List.of(metadata);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public CredentialIssuerMetadata resolveSingle(final ProfileRequestContext criteria) throws ResolverException {
+            if (fail) {
+                throw new ResolverException("Testing");
+            }
+            return metadata;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String getId() {
+            return "stub";
+        }
+
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
index 6e9c066..b19195a 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ValidateCredentialOfferingTest.java
@@ -16,8 +16,13 @@
 
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
+import java.io.IOException;
 import java.net.URI;
+import java.util.List;
 
+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;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialOfferContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequest;
 import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
@@ -34,7 +39,6 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest;
 import net.shibboleth.idp.profile.testing.ActionTestingSupport;
 import net.shibboleth.idp.profile.testing.RequestContextBuilder;
 import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.spring.resource.ResourceHelper;
 import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
 
 /**
@@ -59,7 +63,7 @@ public class ValidateCredentialOfferingTest {
         requestCtx = new RequestContextBuilder().buildRequestContext();
         profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
         action = new ValidateCredentialOffering();
-        action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/verifiable-credentials.json")));
+        action.setCredentialConfigurationsResolver(resolverOf("conf/verifiable-credentials.json"));
         action.initialize();
         profileRequestCtx.setInboundMessageContext(new MessageContext());
         profileRequestCtx.getInboundMessageContext().setMessage(CredentialOfferRequest.parse(httpRequest));
@@ -88,19 +92,30 @@ public class ValidateCredentialOfferingTest {
         ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_REQUEST);
     }
 
+    private CredentialConfigurationsResolver resolverOf(final String path) throws Exception {
+        final FilesystemCredentialConfigurationsResolver resolver = new FilesystemCredentialConfigurationsResolver(
+                new ClassPathResource(path));
+        resolver.setId("test");
+        resolver.initialize();
+        return resolver;
+    }
+
     @Test
-    public void testUnparsableConfiguration() throws ComponentInitializationException {
-        action = new ValidateCredentialOffering();
-        action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/unparsable.txt")));
-        action.initialize();
-        ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
+    public void testUnparsableConfiguration() {
+        org.testng.Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf("conf/unparsable.txt"));
+    }
+
+    @Test
+    public void testNoConfigurationFile() {
+        org.testng.Assert.assertThrows(IOException.class, () -> resolverOf("conf/no.txt"));
     }
 
-    @Test(expectedExceptions = AssertionError.class)
-    public void testNoConfiguration() throws ComponentInitializationException {
+    @Test
+    public void testNoConfigurationResolved() throws Exception {
         action = new ValidateCredentialOffering();
-        action.setCredentialsResource(ResourceHelper.of(new ClassPathResource("conf/no.txt")));
+        action.setCredentialConfigurationsResolver(new EmptyResolver());
         action.initialize();
+
         ActionTestingSupport.assertEvent(action.execute(requestCtx), OpenIDVCIEventIds.NO_CREDENTIAL_CONFIGURATION);
     }
 
@@ -171,4 +186,27 @@ public class ValidateCredentialOfferingTest {
         Assert.notNull(ctx.getValidatedCodeCredentials(), "Must not be null");
     }
 
-}
\ No newline at end of file
+    /** Resolver that has nothing to resolve. */
+    private static class EmptyResolver implements CredentialConfigurationsResolver {
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterable<CredentialConfigurations> resolve(final ProfileRequestContext criteria) {
+            return List.of();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public CredentialConfigurations resolveSingle(final ProfileRequestContext criteria) {
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String getId() {
+            return "empty";
+        }
+
+    }
+
+}
diff --git a/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json b/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json
new file mode 100644
index 0000000..4ce0ecf
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/no-credential-configurations.json
@@ -0,0 +1,3 @@
+{
+   "something_else":{}
+}
diff --git a/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json b/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json
new file mode 100644
index 0000000..44d8d7c
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/conf/unparsable-credential-configuration.json
@@ -0,0 +1,8 @@
+{
+   "credential_configurations_supported":{
+      "Broken":{
+         "format":"dc+sd-jwt",
+         "proof_types_supported":"this must be an object"
+      }
+   }
+}
diff --git a/openid-vci-impl/src/test/resources/credentials/sealer.jks b/openid-vci-impl/src/test/resources/credentials/sealer.jks
deleted file mode 100644
index 5ab65e2..0000000
Binary files a/openid-vci-impl/src/test/resources/credentials/sealer.jks and /dev/null differ
diff --git a/openid-vci-impl/src/test/resources/credentials/sealer.kver b/openid-vci-impl/src/test/resources/credentials/sealer.kver
deleted file mode 100644
index 8a34ad7..0000000
--- a/openid-vci-impl/src/test/resources/credentials/sealer.kver
+++ /dev/null
@@ -1 +0,0 @@
-CurrentVersion=1
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json
new file mode 100644
index 0000000..cd8c7b3
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-no-issuer.json
@@ -0,0 +1,3 @@
+{
+   "credential_endpoint":"https://example.org/idp/profile/openid/vci/credentials"
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json
new file mode 100644
index 0000000..12d7144
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-template.json
@@ -0,0 +1,14 @@
+{
+   "credential_issuer":"$baseUrl",
+   "authorization_servers":[
+      "$issuer"
+   ],
+   "credential_endpoint":"$baseUrl/idp/profile/openid/vci/credentials",
+   "nonce_endpoint":"$baseUrl/idp/profile/openid/vci/nonce",
+   "display":[
+      {
+         "name":"$host",
+         "locale":"en-US"
+      }
+   ]
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json
new file mode 100644
index 0000000..8ff6a40
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer-unresolved.json
@@ -0,0 +1,4 @@
+{
+   "credential_issuer":"$baseUrl",
+   "credential_endpoint":"$notAVariable/credentials"
+}
diff --git a/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json
new file mode 100644
index 0000000..e94b39e
--- /dev/null
+++ b/openid-vci-impl/src/test/resources/metadata/openid-credential-issuer.json
@@ -0,0 +1,14 @@
+{
+   "credential_issuer":"https://example.org",
+   "authorization_servers":[
+      "https://example.org"
+   ],
+   "credential_endpoint":"https://example.org/idp/profile/openid/vci/credentials",
+   "nonce_endpoint":"https://example.org/idp/profile/openid/vci/nonce",
+   "display":[
+      {
+         "name":"Example Credential Issuer",
+         "locale":"en-US"
+      }
+   ]
+}
diff --git a/pom.xml b/pom.xml
index 94d3eec..7a42b0d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,9 @@
     <properties>
         <shibboleth.projectName>java-idp-plugin-vci</shibboleth.projectName>
         <oidc.op.version>4.3.0</oidc.op.version>
+        <!-- Compile only, for the OpenID Federation decorator. Unreleased, see openid-vci-oidfed.xml. -->
+        <oidfed-common.groupId>net.shibboleth.oidfed</oidfed-common.groupId>
+        <oidfed-common.version>1.0.0-SNAPSHOT</oidfed-common.version>
         <shib-shared.groupId>net.shibboleth</shib-shared.groupId>
         <shib-shared.version>9.0.0</shib-shared.version>
         <oidc-common.groupId>net.shibboleth.oidc</oidc-common.groupId>

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


More information about the commits mailing list