[java-idp-plugin-vci] branch main updated: Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests

Codeberg noreply at shibboleth.net
Wed Oct 28 12:51:14 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/5b014fa5b5e662c9a446495327e14f2b1065999f

The following commit(s) were added to refs/heads/main by this push:
     new 5b014fa  Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests
5b014fa is described below

commit 5b014fa5b5e662c9a446495327e14f2b1065999f
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Oct 28 14:50:55 2026 +0200

    Review jwt-vc-issuer flow: intercept, counter, headers, metadata validation, tests
---
 README.md                                          |  15 +-
 .../openidvci/metadata/MetadataDocument.java       |  65 ++++---
 .../openidvci/metadata/MetadataDocumentTest.java   | 129 +++++++++++++
 .../impl/MetadataDocumentSuccessResponse.java      |  65 -------
 .../AbstractTemplatedFileMetadataResolver.java     |  19 +-
 .../FilesystemJWTVCIssuerMetadataResolver.java     | 129 +++++++++++++
 .../impl/DefaultMetadataDocumentConfiguration.java |  11 +-
 .../FormOutboundDidDocumentResponseMessage.java    |  14 +-
 ...ormOutboundMetadataDocumentResponseMessage.java |  64 ++++--
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  12 +-
 .../authorization-server-beans.xml                 |   3 +-
 .../oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml    |  16 +-
 .../oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml     |   6 +-
 .../idp/service/relying-party/postconfig.xml       |  15 +-
 .../plugin/openidvci/conf/openid-vci.properties    |   6 +-
 .../FilesystemJWTVCIssuerMetadataResolverTest.java |  99 ++++++++++
 .../FilesystemMetadataDocumentResolverTest.java    | 144 ++++++++++++++
 .../DefaultMetadataDocumentConfigurationTest.java  |  65 +++++++
 ...utboundMetadataDocumentResponseMessageTest.java | 215 +++++++++++++++++++++
 19 files changed, 918 insertions(+), 174 deletions(-)

diff --git a/README.md b/README.md
index 041ee27..feb2030 100644
--- a/README.md
+++ b/README.md
@@ -4440,8 +4440,10 @@ Endpoint `/idp/profile/openid/vci/issuer-metadata`, profile ID `http://geant.org
 | `issuerMetadataSignatureSigningConfiguration` | SignatureSigningConfiguration | **openidvci.issuerMetadata.SigningConfiguration** | Signing of the metadata. Published unsigned until **openidvci.issuerMetadata.SigningCredentials** exists. Signed metadata is served for `Accept: application/jwt`. |
 | `outboundInterceptorFlows` | List<String> | | Interceptor flows run before the response is sent, also for an error response. |
 
-**OAuth.JWTVCIssuer** (`/idp/profile/oauth/jwt-vc-issuer`) and **OAuth.AuthorizationServer**
-(`/idp/profile/oauth/authorization-server`) have one setting, `outboundInterceptorFlows`, as above.
+**OAuth.JWTVCIssuer** (`/idp/profile/oauth/jwt-vc-issuer`) has two settings,
+`inboundInterceptorFlows`, interceptor flows run before the document is formed, and
+`outboundInterceptorFlows` as above. **OAuth.AuthorizationServer**
+(`/idp/profile/oauth/authorization-server`) has one setting, `outboundInterceptorFlows`.
 
 #### DID.Document
 
@@ -4617,7 +4619,7 @@ Members of a `vc+sd-jwt` credential, [W3C VCDM 2.0](https://www.w3.org/TR/vc-dat
 |---|---|---|---|---|
 | empty or `did:jwk`, the default | did:jwk of the signing key | the credential | nothing | `~did-jwk:<key thumbprint>` |
 | `did:web` | did:web of this deployment, from the host of `idp.oidc.issuer` | `/.well-known/did.json`, header `kid` `did:web:…#<key name>` | **DID.Document** | `~did-web` |
-| `url` or the url itself | `idp.oidc.issuer` | `x5c` when the key has certificates, otherwise `/.well-known/jwt-vc-issuer` | **OAuth.JWTVCIssuer** and the key in **shibboleth.oidc.SigningCredentialsToPublish**, when no `x5c` | `~url` |
+| `url` or the url itself | `idp.oidc.issuer`, or the url | `x5c` when the key has certificates, otherwise `/.well-known/jwt-vc-issuer` | **OAuth.JWTVCIssuer** and the key in **shibboleth.oidc.SigningCredentialsToPublish**, when no `x5c` | `~url` |
 
 The Status List Token of a credential has the same issuer as the credential.
 
@@ -4719,6 +4721,7 @@ The file comes with the plugin and is loaded automatically.
 | `openidvci.jwtVcIssuer.templated` | Boolean | `true` | Whether the file is Velocity. |
 | `openidvci.jwtVcIssuer.resolver` | Bean ID | **openidvci.DefaultJWTVCIssuerMetadataResolver** | Resolver of the document. |
 | `openidvci.jwtVcIssuer.minRefreshDelay`, `…maxRefreshDelay` | Duration | `PT5M`, `PT4H` | Bounds of re-reading the file. |
+| `openidvci.jwtVcIssuer.cacheControl` | String | `max-age=3600` | Cache-Control of the response. |
 | `openidvci.authorizationServer.template` | File pathname | `%{idp.home}/static/oauth-authorization-server.json` | Authorization server metadata template. |
 | `openidvci.authorizationServer.templated` | Boolean | `true` | Whether the file is Velocity. |
 | `openidvci.authorizationServer.resolver` | Bean ID | **openidvci.DefaultAuthorizationServerMetadataResolver** | Resolver of the document. |
@@ -4791,6 +4794,12 @@ The file comes with the plugin and is loaded automatically.
 
 The templates are Velocity, `$issuer`, `$baseUrl` and `$host` are filled in from
 `idp.oidc.issuer` of *conf/oidc.properties*.
+When `idp.oidc.issuer` has a path, the JWT VC issuer metadata is at `/.well-known/jwt-vc-issuer`
+followed by that path, `https://issuer.example.org/tenant` has it at
+`/.well-known/jwt-vc-issuer/tenant`, and the rewrite pattern is that.
+The JWT VC issuer metadata names one issuer. A credential with a url other than
+`idp.oidc.issuer` as its issuer is verified by `x5c`, or needs the `issuer` of the template
+changed to that url.
 `credential_configurations_supported` is added from *metadata/verifiable-credentials.json*. The
 Credential Issuer metadata endpoint answers `500 server_error` when `credential_endpoint` or
 `credential_configurations_supported` is missing. `batch_credential_issuance.batch_size` is the
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
index 8e3ce4e..e39018b 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
@@ -16,6 +16,8 @@
 
 package org.geant.shibboleth.plugin.openidvci.metadata;
 
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -28,10 +30,6 @@ import com.nimbusds.oauth2.sdk.id.Issuer;
 
 /**
  * A metadata document this deployment publishes.
- *
- * Members are carried as they were parsed, this is the document that gets
- * published. Only 'issuer' is parsed, it names the document. Everything else is
- * the deployer's to write.
  */
 public final class MetadataDocument {
 
@@ -78,33 +76,12 @@ public final class MetadataDocument {
         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 an issuer
+     * @throws ParseException if the members do not name a valid issuer
      */
     @Nonnull
     public static MetadataDocument parse(@Nullable final Map<String, Object> members) throws ParseException {
@@ -113,12 +90,40 @@ public final class MetadataDocument {
             throw new ParseException("Metadata document cannot be null");
         }
 
-        final Object issuer = members.get(ISSUER);
-        if (!(issuer instanceof String) || ((String) issuer).isEmpty()) {
+        if (!(members.get(ISSUER) instanceof String value) || value.isEmpty()) {
             throw new ParseException("Metadata document must have a non empty '" + ISSUER + "' member");
         }
-        return new MetadataDocument(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
-                new Issuer((String) issuer));
+        validateIssuer(value);
+        return new MetadataDocument(Collections.unmodifiableMap(new LinkedHashMap<>(members)), new Issuer(value));
+    }
+
+    /**
+     * Check the issuer is an https url with a host, no query, no fragment and no dot-segments.
+     *
+     * @param value issuer
+     * @throws ParseException if the issuer is not such a url
+     */
+    private static void validateIssuer(@Nonnull final String value) throws ParseException {
+
+        final URI uri;
+        try {
+            uri = new URI(value);
+        } catch (final URISyntaxException e) {
+            throw new ParseException("Issuer '" + value + "' is not a url", e);
+        }
+        if (!"https".equals(uri.getScheme()) || uri.getHost() == null) {
+            throw new ParseException("Issuer '" + value + "' is not an https url with a host");
+        }
+        if (uri.getRawQuery() != null || uri.getRawFragment() != null) {
+            throw new ParseException("Issuer '" + value + "' has a query or a fragment");
+        }
+        if (uri.getRawPath() != null) {
+            for (final String segment : uri.getRawPath().split("/", -1)) {
+                if (".".equals(segment) || "..".equals(segment)) {
+                    throw new ParseException("Issuer '" + value + "' has a dot-segment in its path");
+                }
+            }
+        }
     }
 
 }
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java
new file mode 100644
index 0000000..0ab026c
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocumentTest.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.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.DataProvider;
+import org.testng.annotations.Test;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * Unit tests for {@link MetadataDocument}.
+ */
+public class MetadataDocumentTest {
+
+    private Map<String, Object> members(final Object issuer) {
+        final Map<String, Object> members = new LinkedHashMap<>();
+        members.put(MetadataDocument.ISSUER, issuer);
+        members.put("jwks_uri", "https://example.org/keyset");
+        members.put("display", List.of(Map.of("name", "Example")));
+        return members;
+    }
+
+    @DataProvider
+    public Object[][] validIssuers() {
+        return new Object[][] {
+            {"https://example.org"},
+            {"https://example.org/"},
+            {"https://example.org:8443"},
+            {"https://example.org/tenant/1234"},
+            {"https://example.org/a.b/.well"},
+        };
+    }
+
+    @DataProvider
+    public Object[][] invalidIssuers() {
+        return new Object[][] {
+            {""},
+            {"http://example.org"},
+            {"HTTPS://example.org"},
+            {"https:/example.org"},
+            {"example.org"},
+            {"https://example.org?tenant=1"},
+            {"https://example.org/?"},
+            {"https://example.org#top"},
+            {"https://example.org/#"},
+            {"https://example.org/./tenant"},
+            {"https://example.org/tenant/.."},
+            {"https://example.org/a/../b"},
+            {"https://exa mple.org"},
+        };
+    }
+
+    @Test(dataProvider = "validIssuers")
+    public void testValidIssuer(final String issuer) throws ParseException {
+        final MetadataDocument document = MetadataDocument.parse(members(issuer));
+
+        Assert.assertEquals(document.getIssuer().getValue(), issuer);
+    }
+
+    @Test(dataProvider = "invalidIssuers")
+    public void testInvalidIssuer(final String issuer) {
+        Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(members(issuer)));
+    }
+
+    @Test
+    public void testMemberOrderIsPreserved() throws ParseException {
+        final MetadataDocument document = MetadataDocument.parse(members("https://example.org"));
+
+        Assert.assertEquals(new ArrayList<>(document.getMembers().keySet()),
+                List.of(MetadataDocument.ISSUER, "jwks_uri", "display"));
+    }
+
+    @Test
+    public void testMembersAreNotModifiable() throws ParseException {
+        final MetadataDocument document = MetadataDocument.parse(members("https://example.org"));
+
+        Assert.assertThrows(UnsupportedOperationException.class, () -> document.getMembers().put("x", "y"));
+    }
+
+    @Test
+    public void testParsedDocumentIsDetachedFromInput() throws ParseException {
+        final Map<String, Object> input = members("https://example.org");
+        final MetadataDocument document = MetadataDocument.parse(input);
+
+        input.put("jwks", Map.of("keys", List.of()));
+
+        Assert.assertFalse(document.getMembers().containsKey("jwks"));
+    }
+
+    @Test
+    public void testNoMembers() {
+        Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(null));
+    }
+
+    @Test
+    public void testNoIssuer() {
+        final Map<String, Object> members = members("https://example.org");
+        members.remove(MetadataDocument.ISSUER);
+
+        Assert.assertThrows(ParseException.class, () -> MetadataDocument.parse(members));
+    }
+
+    @Test
+    public void testIssuerNotAString() {
+        Assert.assertThrows(ParseException.class,
+                () -> MetadataDocument.parse(members(List.of("https://example.org"))));
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
deleted file mode 100644
index 24dab16..0000000
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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 a metadata document this deployment publishes.
- */
-public class MetadataDocumentSuccessResponse implements SuccessResponse {
-
-    /** Serialized metadata document. */
-    @Nonnull
-    private final String content;
-
-    /**
-     * Constructor.
-     *
-     * @param document members of the metadata document
-     *
-     * @throws JsonProcessingException if the members cannot be serialized
-     */
-    public MetadataDocumentSuccessResponse(@Nonnull final Map<String, Object> document)
-            throws JsonProcessingException {
-        content = new ObjectMapper().writeValueAsString(document);
-    }
-
-    /** {@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/AbstractTemplatedFileMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
index 5f2a4eb..b1a4038 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
@@ -51,14 +51,9 @@ import net.shibboleth.shared.resolver.ResolverException;
 import net.shibboleth.shared.velocity.Template;
 
 /**
- * A metadata document read from a file, with the refresh behaviour of
+ * A metadata document read from a file, optionally a Velocity template, refreshed as
  * {@link AbstractFileOIDCEntityResolver}.
  *
- * 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 given to this resolver, and a deployer adds to them
- * or replaces them with a template context of their own.
- *
  * @param <T> type of the document being read
  */
 public abstract class AbstractTemplatedFileMetadataResolver<T>
@@ -118,9 +113,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
     /**
      * 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) {
@@ -131,9 +123,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
     /**
      * 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) {
@@ -142,10 +131,7 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
     }
 
     /**
-     * Set the variables the deployer adds to the template.
-     *
-     * These are applied after the derived ones, so a deployer may also replace
-     * those.
+     * Set the variables the deployer adds to, or replaces in, the template.
      *
      * @param context variables to add
      */
@@ -221,7 +207,6 @@ public abstract class AbstractTemplatedFileMetadataResolver<T>
         } catch (final VelocityException e) {
             throw new ParseException("Unable to evaluate the metadata document template: " + e.getMessage(), e);
         } finally {
-            // fromTemplate registers the template in a repository that outlives it.
             if (compiled != null) {
                 StringResourceLoader.getRepository().removeStringResource(compiled.getTemplateName());
             }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java
new file mode 100644
index 0000000..1f6a809
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolver.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.oauth2.sdk.ParseException;
+
+/**
+ * JWT VC Issuer Metadata read from a file, as in <a href=
+ * "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc#name-jwt-vc-issuer-metadata-response">
+ * SD-JWT VC</a>.
+ */
+public class FilesystemJWTVCIssuerMetadataResolver extends FilesystemMetadataDocumentResolver {
+
+    /** Member carrying the JWK Set by value. */
+    @Nonnull
+    public static final String JWKS = "jwks";
+
+    /** Member referencing the JWK Set. */
+    @Nonnull
+    public static final String JWKS_URI = "jwks_uri";
+
+    /**
+     * Constructor.
+     *
+     * @param metadata the metadata file
+     *
+     * @throws IOException if the metadata cannot be loaded
+     */
+    public FilesystemJWTVCIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+        super(metadata);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected MetadataDocument fromMembers(@Nonnull final Map<String, Object> members) throws ParseException {
+
+        final MetadataDocument document = super.fromMembers(members);
+        final Object jwks = document.getMembers().get(JWKS);
+        final Object jwksUri = document.getMembers().get(JWKS_URI);
+
+        if ((jwks == null) == (jwksUri == null)) {
+            throw new ParseException("JWT VC Issuer Metadata must have exactly one of '" + JWKS + "' and '"
+                    + JWKS_URI + "'");
+        }
+        if (jwks != null) {
+            validateJwks(jwks);
+        } else {
+            validateJwksUri(jwksUri);
+        }
+        return document;
+    }
+
+    /**
+     * Check the value is a JWK Set of public keys.
+     *
+     * @param value value of {@value #JWKS}
+     * @throws ParseException if the value is not such a JWK Set
+     */
+    @SuppressWarnings("unchecked")
+    private static void validateJwks(@Nonnull final Object value) throws ParseException {
+
+        if (!(value instanceof Map)) {
+            throw new ParseException("'" + JWKS + "' is not a JSON object");
+        }
+        final JWKSet set;
+        try {
+            set = JWKSet.parse((Map<String, Object>) value);
+        } catch (final java.text.ParseException e) {
+            throw new ParseException("'" + JWKS + "' is not a JWK Set: " + e.getMessage(), e);
+        }
+        if (set.getKeys().isEmpty()) {
+            throw new ParseException("'" + JWKS + "' has no keys");
+        }
+        for (final JWK key : set.getKeys()) {
+            if (key.isPrivate()) {
+                throw new ParseException("'" + JWKS + "' has a private key");
+            }
+        }
+    }
+
+    /**
+     * Check the value is an https url with a host.
+     *
+     * @param value value of {@value #JWKS_URI}
+     * @throws ParseException if the value is not such a url
+     */
+    private static void validateJwksUri(@Nonnull final Object value) throws ParseException {
+
+        if (!(value instanceof String uri)) {
+            throw new ParseException("'" + JWKS_URI + "' is not a string");
+        }
+        try {
+            final URI parsed = new URI(uri);
+            if (!"https".equals(parsed.getScheme()) || parsed.getHost() == null) {
+                throw new ParseException("'" + JWKS_URI + "' " + uri + " is not an https url with a host");
+            }
+        } catch (final URISyntaxException e) {
+            throw new ParseException("'" + JWKS_URI + "' " + uri + " is not a url", e);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
index 0639be5..b768fc4 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
@@ -23,9 +23,6 @@ import net.shibboleth.shared.annotation.constraint.NotEmpty;
 
 /**
  * Profile configuration for an endpoint publishing a metadata document.
- *
- * There is nothing of the document to configure, so one class serves every such
- * endpoint and the profile is the identifier given to it.
  */
 public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwareProfileConfiguration {
 
@@ -34,10 +31,16 @@ public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwa
     @NotEmpty
     public static final String PROFILE_ID_JWT_VC_ISSUER = "http://geant.org/ns/profiles/oauth/jwt-vc-issuer";
 
+    /** JWT VC issuer metadata profile counter name. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_COUNTER_JWT_VC_ISSUER = "net.shibboleth.idp.profiles.oauth.jwt-vc-issuer";
+
     /** ID for the authorization server metadata profile configuration. */
     @Nonnull
     @NotEmpty
-    public static final String PROFILE_ID_AUTHORIZATION_SERVER = "http://geant.org/ns/profiles/oauth/authorization-server";
+    public static final String PROFILE_ID_AUTHORIZATION_SERVER =
+            "http://geant.org/ns/profiles/oauth/authorization-server";
 
     /** ID for the did:web document profile configuration. */
     @Nonnull
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
index 77a3476..383a702 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
@@ -27,7 +27,6 @@ import java.util.function.Function;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.profile.config.navigate.DidDocumentSignatureSigningConfigurationLookupFunction;
 import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
 import org.opensaml.profile.action.ActionSupport;
@@ -36,10 +35,11 @@ import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.security.credential.Credential;
 import org.slf4j.Logger;
 
-import com.fasterxml.jackson.core.JsonProcessingException;
 import com.nimbusds.jose.jwk.JWK;
 
+import net.minidev.json.JSONObject;
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.profile.messaging.JSONSuccessResponse;
 import net.shibboleth.oidc.security.CredentialConversionUtil;
 import net.shibboleth.oidc.security.credential.JWKCredential;
 import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
@@ -183,14 +183,8 @@ public class FormOutboundDidDocumentResponseMessage extends AbstractProfileActio
         document.put(VERIFICATION_METHOD, methods);
         document.put(ASSERTION_METHOD, assertions);
 
-        try {
-            profileRequestContext.ensureOutboundMessageContext()
-                    .setMessage(new MetadataDocumentSuccessResponse(document));
-        } catch (final JsonProcessingException e) {
-            log.error("{} Could not serialize the did:web document", getLogPrefix(), e);
-            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
-            return;
-        }
+        profileRequestContext.ensureOutboundMessageContext()
+                .setMessage(new JSONSuccessResponse(new JSONObject(document)));
 
         log.debug("{} Publishing the did:web document of {}, verification methods {}", getLogPrefix(), didWeb, stated);
     }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
index b33e05c..8a6d979 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
@@ -17,8 +17,10 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import jakarta.servlet.http.HttpServletResponse;
 
-import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
 import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
 import org.opensaml.profile.action.ActionSupport;
@@ -26,22 +28,36 @@ import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 
-import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.net.HttpHeaders;
 
+import net.minidev.json.JSONObject;
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.messaging.JSONSuccessResponse;
 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.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
 import net.shibboleth.shared.resolver.ResolverException;
 
 /**
- * Action forming {@link MetadataDocumentSuccessResponse} from the document the
- * attached {@link MetadataDocumentResolver} resolves.
+ * Action forming {@link JSONSuccessResponse} from the document the attached
+ * {@link MetadataDocumentResolver} resolves.
  */
 public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfileAction {
 
+    /** Value of the X-Content-Type-Options header. */
+    @Nonnull
+    @NotEmpty
+    public static final String NOSNIFF = "nosniff";
+
+    /** Value of the Content-Security-Policy header. */
+    @Nonnull
+    @NotEmpty
+    public static final String CONTENT_SECURITY_POLICY = "default-src 'none'";
+
     /** Class logger. */
     @Nonnull
     private final Logger log = LoggerFactory.getLogger(FormOutboundMetadataDocumentResponseMessage.class);
@@ -50,10 +66,20 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
     @NonnullAfterInit
     private MetadataDocumentResolver metadataResolver;
 
+    /** Value of the Cache-Control header. */
+    @Nonnull
+    @NotEmpty
+    private String cacheControl;
+
     /** Document to publish. */
-    @NonnullBeforeExec
+    @Nullable
     private MetadataDocument document;
 
+    /** Constructor. */
+    public FormOutboundMetadataDocumentResponseMessage() {
+        cacheControl = "max-age=3600";
+    }
+
     /**
      * Set the resolver of the document that is being published.
      *
@@ -64,6 +90,16 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
         metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
     }
 
+    /**
+     * Set the value of the Cache-Control header.
+     *
+     * @param value header value
+     */
+    public void setCacheControl(@Nonnull @NotEmpty final String value) {
+        checkSetterPreconditions();
+        cacheControl = Constraint.isNotNull(StringSupport.trimOrNull(value), "Cache-Control cannot be null or empty");
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -104,14 +140,18 @@ public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfile
 
         assert document != null;
 
-        try {
-            profileRequestContext.ensureOutboundMessageContext()
-                    .setMessage(new MetadataDocumentSuccessResponse(document.getMembers()));
-        } catch (final JsonProcessingException e) {
-            log.error("{} Could not serialize the metadata document", getLogPrefix(), e);
-            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        final HttpServletResponse response = getHttpServletResponse();
+        if (response == null) {
+            log.error("{} Could not fetch HttpServletResponse", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
             return;
         }
+        response.setHeader(HttpHeaders.CACHE_CONTROL, cacheControl);
+        response.setHeader(HttpHeaders.X_CONTENT_TYPE_OPTIONS, NOSNIFF);
+        response.setHeader(HttpHeaders.CONTENT_SECURITY_POLICY, CONTENT_SECURITY_POLICY);
+
+        profileRequestContext.ensureOutboundMessageContext()
+                .setMessage(new JSONSuccessResponse(new JSONObject(document.getMembers())));
 
         log.debug("{} Publishing the metadata document of {}, members {}", getLogPrefix(),
                 document.getIssuer(), document.getMembers().keySet());
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 b9ef989..a8c9d8b 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
@@ -154,12 +154,9 @@
     <bean id="openidvci.CredentialsResource" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
         c:path="%{openidvci.credentialConfigurations.resource:%{idp.home}/metadata/verifiable-credentials.json}" />
 
-    <!--
-    JWT VC issuer metadata, published at /.well-known/jwt-vc-issuer. A Velocity template
-    with the same $issuer, $baseUrl and $host variables as the document above.
-    -->
+    <!-- JWT VC issuer metadata, published at /.well-known/jwt-vc-issuer. -->
     <bean id="openidvci.DefaultJWTVCIssuerMetadataResolver" lazy-init="true"
-        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemJWTVCIssuerMetadataResolver"
         depends-on="shibboleth.LoggingService"
         p:id="openidvci.DefaultJWTVCIssuerMetadataResolver"
         p:minRefreshDelay="%{openidvci.jwtVcIssuer.minRefreshDelay:PT5M}"
@@ -173,10 +170,7 @@
         lazy-init="true"
         c:path="%{openidvci.jwtVcIssuer.template:%{idp.home}/static/jwt-vc-issuer.json}" />
 
-    <!--
-    Authorization server metadata, published at /.well-known/oauth-authorization-server. A
-    Velocity template as above, and everything in it is the deployer's to write.
-    -->
+    <!-- Authorization server metadata, published at /.well-known/oauth-authorization-server. -->
     <bean id="openidvci.DefaultAuthorizationServerMetadataResolver" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
         depends-on="shibboleth.LoggingService"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
index a1a963d..2445724 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
@@ -21,7 +21,8 @@
   <bean id="FormOutboundMessage"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundMetadataDocumentResponseMessage"
         scope="prototype"
-        p:metadataResolver-ref="#{'%{openidvci.authorizationServer.resolver:openidvci.DefaultAuthorizationServerMetadataResolver}'.trim()}" />
+        p:metadataResolver-ref="#{'%{openidvci.authorizationServer.resolver:openidvci.DefaultAuthorizationServerMetadataResolver}'.trim()}"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier" />
 
   <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
         scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
index 595644a..1499d35 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
@@ -11,17 +11,23 @@
                            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="openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/jwt-vc-issuer"/>
+  <bean id="openidvci.profileId" class="java.lang.String"
+        c:_0="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_JWT_VC_ISSUER}"/>
   <bean id="openidvci.loggingId" class="java.lang.String"
         c:_0="%{openidvci.logging.jwt-vc-issuer:OAuth.JWTVCIssuer}"/>
 
+  <util:constant id="shibboleth.metrics.ProfileCounter"
+        static-field="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_JWT_VC_ISSUER"/>
+
   <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.FormOutboundMetadataDocumentResponseMessage"
         scope="prototype"
-        p:metadataResolver-ref="#{'%{openidvci.jwtVcIssuer.resolver:openidvci.DefaultJWTVCIssuerMetadataResolver}'.trim()}" />
+        p:metadataResolver-ref="#{'%{openidvci.jwtVcIssuer.resolver:openidvci.DefaultJWTVCIssuerMetadataResolver}'.trim()}"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier"
+        p:cacheControl="%{openidvci.jwtVcIssuer.cacheControl:max-age=3600}" />
 
   <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
         scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
@@ -29,12 +35,6 @@
     <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/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
index e4bfe05..f18032c 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
@@ -5,6 +5,9 @@
       parent="openid/vci/abstract-api">
 
   <action-state id="InitializeMandatoryContexts">
+    <on-entry>
+      <set name="flowScope.transitionAfterInboundIntercept" value="'BuildResponseMessage'"/>
+    </on-entry>
     <evaluate expression="InitializeProfileRequestContext" />
     <evaluate expression="PopulateMetricContext" />
     <evaluate expression="FlowStartPopulateAuditContext" />
@@ -12,9 +15,10 @@
     <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
     <evaluate expression="SelectRelyingPartyConfiguration" />
     <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="PopulateInboundInterceptContext" />
     <evaluate expression="'proceed'" />
 
-    <transition on="proceed" to="BuildResponseMessage" />
+    <transition on="proceed" to="CheckInboundInterceptContext" />
   </action-state>
 
   <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml" />
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 f609604..d180ad0 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
@@ -52,19 +52,13 @@
         p:issuedClaimsValidator="#{getObject('openidvci.DefaultAccessTokenClaimsValidator')}"
         p:dpopProofClaimsValidator="#{getObject('openidvci.DefaultDPoPWithAthProofClaimsValidator')}" />
 
-    <!--
-    Endpoint publishing Credential Issuer metadata. Serves wallets before there is a
-    client, so there is nothing of the VCI profile settings to inherit here.
-    -->
+    <!-- Endpoint publishing Credential Issuer metadata. -->
     <bean id="OpenID.VCI.IssuerMetadata" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultOpenIDVCIIssuerMetadataConfiguration"
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
         p:issuerMetadataSignatureSigningConfiguration-ref="openidvci.issuerMetadata.SigningConfiguration" />
 
-    <!--
-    Endpoints publishing a metadata document. They serve anyone, so there is nothing of the
-    VCI profile settings to inherit and nothing of the document to configure here.
-    -->
+    <!-- Endpoints publishing a metadata document. -->
     <bean id="OAuth.JWTVCIssuer" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration"
         c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_JWT_VC_ISSUER}"
@@ -75,10 +69,7 @@
         c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_AUTHORIZATION_SERVER}"
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
 
-    <!--
-    Endpoint publishing the did:web document of this deployment, the keys a verifier
-    resolves a credential of a did:web issuer by. Serves anyone, as the two above do.
-    -->
+    <!-- Endpoint publishing the did:web document of this deployment. -->
     <bean id="DID.Document" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultDidDocumentConfiguration"
         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.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index 7888d6f..fa7c9c6 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
@@ -83,9 +83,9 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 #openidvci.status-list.index.StorageService = shibboleth.StorageService
 
 # Settings for the jwt-vc-issuer flow
-# Flow is available at /openid/vci/../oauth/jwt-vc-issuer, usually it should be wired from
+# Flow is available at /oauth/jwt-vc-issuer, usually it should be wired from
 # /.well-known/jwt-vc-issuer. A verifier reads that document to find the keys of the issuer, but
-# only when a credential names its issuer by url. The default Credential Issuer identifier is
+# only when a credential names its issuer by url and its key has no certificates. The default Credential Issuer identifier is
 # did:jwk, which carries the key inside the credential, so nothing reads the document until you
 # set openidvci.issuer.
 #openidvci.jwtVcIssuer.template = %{idp.home}/static/jwt-vc-issuer.json
@@ -96,6 +96,8 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 # Bounds on the next file refresh of the document
 #openidvci.jwtVcIssuer.minRefreshDelay = PT5M
 #openidvci.jwtVcIssuer.maxRefreshDelay = PT4H
+# Cache-Control of the response, so a verifier does not fetch the document on every credential
+#openidvci.jwtVcIssuer.cacheControl = max-age=3600
 
 # Settings for the authorization-server flow
 # Flow is available at /oauth/authorization-server, usually it should be wired from
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.java
new file mode 100644
index 0000000..e9138fb
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemJWTVCIssuerMetadataResolverTest.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.metadata.impl;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemJWTVCIssuerMetadataResolver}.
+ */
+public class FilesystemJWTVCIssuerMetadataResolverTest {
+
+    private ECKey key;
+
+    @BeforeClass
+    protected void setUp() throws Exception {
+        key = new ECKeyGenerator(Curve.P_256).keyID("issuer-key").generate();
+    }
+
+    private FilesystemJWTVCIssuerMetadataResolver resolverOf(final String content) throws Exception {
+        final FilesystemJWTVCIssuerMetadataResolver resolver = new FilesystemJWTVCIssuerMetadataResolver(
+                FilesystemMetadataDocumentResolverTest.fileOf(content));
+        resolver.setId("test");
+        resolver.initialize();
+        return resolver;
+    }
+
+    private String document(final String members) {
+        return "{\"issuer\":\"https://example.org\"" + members + "}";
+    }
+
+    private String jwks(final boolean privateKey) {
+        return "{\"keys\":[" + (privateKey ? key : key.toPublicJWK()).toJSONString() + "]}";
+    }
+
+    @Test
+    public void testJwksUri() throws Exception {
+        final MetadataDocument document = resolverOf(document(",\"jwks_uri\":\"https://example.org/keyset\""))
+                .resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getMembers().get(FilesystemJWTVCIssuerMetadataResolver.JWKS_URI),
+                "https://example.org/keyset");
+    }
+
+    @Test
+    public void testJwks() throws Exception {
+        final MetadataDocument document = resolverOf(document(",\"jwks\":" + jwks(false))).resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertTrue(document.getMembers().containsKey(FilesystemJWTVCIssuerMetadataResolver.JWKS));
+    }
+
+    @DataProvider
+    public Object[][] invalidMembers() {
+        return new Object[][] {
+            {""},
+            {",\"jwks_uri\":\"https://example.org/keyset\",\"jwks\":" + jwks(false)},
+            {",\"jwks_uri\":\"http://example.org/keyset\""},
+            {",\"jwks_uri\":\"/idp/profile/oidc/keyset\""},
+            {",\"jwks_uri\":\"https://exa mple.org/keyset\""},
+            {",\"jwks_uri\":[\"https://example.org/keyset\"]"},
+            {",\"jwks\":\"https://example.org/keyset\""},
+            {",\"jwks\":{\"keys\":[]}"},
+            {",\"jwks\":{\"keys\":[{\"kty\":\"EC\"}]}"},
+            {",\"jwks\":{\"no-keys\":[]}"},
+            {",\"jwks\":" + jwks(true)},
+        };
+    }
+
+    @Test(dataProvider = "invalidMembers")
+    public void testInvalid(final String members) {
+        Assert.assertThrows(ComponentInitializationException.class, () -> resolverOf(document(members)));
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java
new file mode 100644
index 0000000..1ca3804
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolverTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Map;
+
+import org.apache.velocity.app.VelocityEngine;
+import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.springframework.core.io.FileSystemResource;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.component.ComponentInitializationException;
+
+/**
+ * Unit tests for {@link FilesystemMetadataDocumentResolver}.
+ */
+public class FilesystemMetadataDocumentResolverTest {
+
+    private static final String TEMPLATE = "{\"issuer\":\"$issuer\",\"jwks_uri\":\"$baseUrl/idp/profile/oidc/keyset\","
+            + "\"host\":\"$host\"}";
+
+    static FileSystemResource fileOf(final String content) throws Exception {
+        final File file = Files.createTempFile("metadata-document", ".json").toFile();
+        file.deleteOnExit();
+        Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);
+        return new FileSystemResource(file);
+    }
+
+    static 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 FilesystemMetadataDocumentResolver resolverOf(final String content, final boolean templated,
+            final String issuer, final Map<String, ?> context) throws Exception {
+        final FilesystemMetadataDocumentResolver resolver = new FilesystemMetadataDocumentResolver(fileOf(content));
+        resolver.setId("test");
+        if (templated) {
+            resolver.setVelocityEngine(velocityEngine());
+            resolver.setIssuer(issuer);
+            resolver.setTemplateContext(context);
+        }
+        resolver.initialize();
+        return resolver;
+    }
+
+    @Test
+    public void testFile() throws Exception {
+        final MetadataDocument document = resolverOf(
+                "{\"issuer\":\"https://example.org\",\"jwks_uri\":\"https://example.org/keyset\"}", false, null, null)
+                .resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getIssuer().getValue(), "https://example.org");
+        Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://example.org/keyset");
+    }
+
+    @Test
+    public void testTemplate() throws Exception {
+        final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org", null).resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getIssuer().getValue(), "https://example.org");
+        Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://example.org/idp/profile/oidc/keyset");
+        Assert.assertEquals(document.getMembers().get("host"), "example.org");
+    }
+
+    @Test
+    public void testTemplateIssuerWithPathAndPort() throws Exception {
+        final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org:8443/tenant", null)
+                .resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getIssuer().getValue(), "https://example.org:8443/tenant");
+        Assert.assertEquals(document.getMembers().get("jwks_uri"),
+                "https://example.org:8443/idp/profile/oidc/keyset");
+        Assert.assertEquals(document.getMembers().get("host"), "example.org");
+    }
+
+    @Test
+    public void testTemplateContextReplacesVariable() throws Exception {
+        final MetadataDocument document = resolverOf(TEMPLATE, true, "https://example.org",
+                Map.of("baseUrl", "https://keys.example.org")).resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getMembers().get("jwks_uri"), "https://keys.example.org/idp/profile/oidc/keyset");
+    }
+
+    @Test
+    public void testNotTemplated() throws Exception {
+        final MetadataDocument document = resolverOf(
+                "{\"issuer\":\"https://example.org\",\"note\":\"$issuer #if\"}", false, null, null).resolveSingle(null);
+
+        Assert.assertNotNull(document);
+        Assert.assertEquals(document.getMembers().get("note"), "$issuer #if");
+    }
+
+    @Test
+    public void testInvalidIssuer() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("{\"issuer\":\"http://example.org\"}", false, null, null));
+    }
+
+    @Test
+    public void testNoIssuer() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("{\"jwks_uri\":\"https://example.org/keyset\"}", false, null, null));
+    }
+
+    @Test
+    public void testNotAnObject() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf("[\"https://example.org\"]", false, null, null));
+    }
+
+    @Test
+    public void testIssuerWithoutHost() {
+        Assert.assertThrows(ComponentInitializationException.class,
+                () -> resolverOf(TEMPLATE, true, "example.org", null));
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java
new file mode 100644
index 0000000..7fa7185
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfigurationTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.config.impl;
+
+import java.util.List;
+import java.util.Set;
+
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/** Tests for {@link DefaultMetadataDocumentConfiguration}. */
+public class DefaultMetadataDocumentConfigurationTest {
+
+    @DataProvider
+    public Object[][] profileIds() {
+        return new Object[][] {
+            {DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER},
+            {DefaultMetadataDocumentConfiguration.PROFILE_ID_AUTHORIZATION_SERVER},
+            {DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT},
+        };
+    }
+
+    @Test(dataProvider = "profileIds")
+    public void testId(final String id) {
+        Assert.assertEquals(new DefaultMetadataDocumentConfiguration(id).getId(), id);
+    }
+
+    @Test
+    public void testIdsAreDistinct() {
+        Assert.assertEquals(Set.of(DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER,
+                DefaultMetadataDocumentConfiguration.PROFILE_ID_AUTHORIZATION_SERVER,
+                DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT).size(), 3);
+    }
+
+    @Test
+    public void testDidDocumentId() {
+        Assert.assertEquals(new DefaultDidDocumentConfiguration().getId(),
+                DefaultMetadataDocumentConfiguration.PROFILE_ID_DID_DOCUMENT);
+    }
+
+    @Test
+    public void testNoInterceptorFlowsByDefault() {
+        final DefaultMetadataDocumentConfiguration configuration =
+                new DefaultMetadataDocumentConfiguration(DefaultMetadataDocumentConfiguration.PROFILE_ID_JWT_VC_ISSUER);
+
+        Assert.assertEquals(configuration.getInboundInterceptorFlows(null), List.of());
+        Assert.assertEquals(configuration.getOutboundInterceptorFlows(null), List.of());
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java
new file mode 100644
index 0000000..6b230b7
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessageTest.java
@@ -0,0 +1,215 @@
+/*
+ * 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.metadata.MetadataDocument;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.webflow.execution.RequestContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+import jakarta.servlet.http.HttpServletResponse;
+import net.minidev.json.JSONObject;
+import net.minidev.json.JSONValue;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.idp.profile.context.navigate.WebflowRequestContextProfileRequestContextLookup;
+import net.shibboleth.idp.profile.testing.ActionTestingSupport;
+import net.shibboleth.idp.profile.testing.RequestContextBuilder;
+import net.shibboleth.oidc.profile.messaging.JSONSuccessResponse;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Unit tests for {@link FormOutboundMetadataDocumentResponseMessage}.
+ */
+public class FormOutboundMetadataDocumentResponseMessageTest {
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private RequestContext requestCtx;
+
+    private MockHttpServletResponse httpResponse;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+        httpResponse = new MockHttpServletResponse();
+    }
+
+    private Map<String, Object> members() {
+        final Map<String, Object> members = new LinkedHashMap<>();
+        members.put(MetadataDocument.ISSUER, "https://example.org");
+        members.put("jwks_uri", "https://example.org/idp/profile/oidc/keyset");
+        return members;
+    }
+
+    private FormOutboundMetadataDocumentResponseMessage actionOf(final MetadataDocument document,
+            final boolean failing, final boolean withResponse) throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action = new FormOutboundMetadataDocumentResponseMessage();
+        action.setMetadataResolver(new StubResolver(document, failing));
+        if (withResponse) {
+            action.setHttpServletResponseSupplier(new NonnullSupplier<>() {
+                public HttpServletResponse get() {
+                    return httpResponse;
+                }
+            });
+        }
+        return action;
+    }
+
+    @Test
+    public void testSuccess() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action =
+                actionOf(MetadataDocument.parse(members()), false, true);
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+        Assert.assertTrue(message instanceof JSONSuccessResponse);
+        final HTTPResponse response = ((JSONSuccessResponse) message).toHTTPResponse();
+        Assert.assertEquals(response.getStatusCode(), HTTPResponse.SC_OK);
+        Assert.assertEquals(response.getEntityContentType().getBaseType(), ContentType.APPLICATION_JSON.getBaseType());
+        final JSONObject published = (JSONObject) JSONValue.parse(response.getContent());
+        Assert.assertEquals(published, new JSONObject(members()));
+    }
+
+    @Test
+    public void testDefaultHeaders() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action =
+                actionOf(MetadataDocument.parse(members()), false, true);
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        Assert.assertEquals(httpResponse.getHeaders("Cache-Control"), List.of("max-age=3600"));
+        Assert.assertEquals(httpResponse.getHeaders("X-Content-Type-Options"),
+                List.of(FormOutboundMetadataDocumentResponseMessage.NOSNIFF));
+        Assert.assertEquals(httpResponse.getHeaders("Content-Security-Policy"),
+                List.of(FormOutboundMetadataDocumentResponseMessage.CONTENT_SECURITY_POLICY));
+    }
+
+    @Test
+    public void testHeadersReplacePreviousValues() throws Exception {
+        httpResponse.addHeader("Cache-Control", "no-store");
+        httpResponse.addHeader("X-Content-Type-Options", "other");
+        final FormOutboundMetadataDocumentResponseMessage action =
+                actionOf(MetadataDocument.parse(members()), false, true);
+        action.setCacheControl(" max-age=60 ");
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        Assert.assertEquals(httpResponse.getHeaders("Cache-Control"), List.of("max-age=60"));
+        Assert.assertEquals(httpResponse.getHeaders("X-Content-Type-Options"),
+                List.of(FormOutboundMetadataDocumentResponseMessage.NOSNIFF));
+    }
+
+    @Test
+    public void testEmptyCacheControl() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action =
+                actionOf(MetadataDocument.parse(members()), false, true);
+
+        Assert.assertThrows(ConstraintViolationException.class, () -> action.setCacheControl(" "));
+    }
+
+    @Test
+    public void testNoDocument() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action = actionOf(null, false, true);
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+        Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+    }
+
+    @Test
+    public void testResolverFails() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action = actionOf(null, true, true);
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+    }
+
+    @Test
+    public void testNoHttpServletResponse() throws Exception {
+        final FormOutboundMetadataDocumentResponseMessage action =
+                actionOf(MetadataDocument.parse(members()), false, false);
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+        Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+    }
+
+    @Test(expectedExceptions = ComponentInitializationException.class)
+    public void testNoResolver() throws Exception {
+        new FormOutboundMetadataDocumentResponseMessage().initialize();
+    }
+
+    private static class StubResolver implements MetadataDocumentResolver {
+
+        private final MetadataDocument document;
+
+        private final boolean fail;
+
+        StubResolver(final MetadataDocument metadata, final boolean failing) {
+            document = metadata;
+            fail = failing;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterable<MetadataDocument> resolve(final ProfileRequestContext criteria) throws ResolverException {
+            if (fail) {
+                throw new ResolverException("Testing");
+            }
+            return document == null ? List.of() : List.of(document);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public MetadataDocument resolveSingle(final ProfileRequestContext criteria) throws ResolverException {
+            if (fail) {
+                throw new ResolverException("Testing");
+            }
+            return document;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String getId() {
+            return "stub";
+        }
+
+    }
+
+}

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


More information about the commits mailing list