[java-idp-plugin-vci] 01/01: Signed credential issuer metadata, common signer used for signing, kid resolving to use common signing

Codeberg noreply at shibboleth.net
Thu Sep 3 12:13:33 UTC 2026


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

codeberg pushed a commit to branch dev/SignedConf
in repository java-idp-plugin-vci.

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

commit 93eaf02fb0e79c767069d73b947c30f322c162d4
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Sep 3 15:13:15 2026 +0300

    Signed credential issuer metadata, common signer used for signing, kid resolving to use common signing
---
 .../plugin/oauth/security/DidJwkSupport.java       | 112 +++++++++++
 .../profile/impl/SignStatusListToken.java          |  73 ++-----
 .../openidvci/config/OpenIDVCIConfiguration.java   |  33 ++--
 ...efaultOpenIDVCIIssuerMetadataConfiguration.java |  59 ++++++
 ...ignatureSigningConfigurationLookupFunction.java |  58 ++++++
 .../CredentialIssuerMetadataSuccessResponse.java   |   1 +
 ...edCredentialIssuerMetadataSuccessResponse.java} |  24 +--
 .../FormOutboundIssuerMetadataResponseMessage.java | 180 ++++++++++++++++-
 .../openidvci/profile/impl/SignJWTCredential.java  |  95 ++-------
 .../vci/issuer-metadata/issuer-metadata-beans.xml  |  25 ++-
 .../vci/issuer-metadata/issuer-metadata-flow.xml   |   6 +-
 .../idp/service/relying-party/postconfig.xml       |  23 +--
 .../openidvci/conf/openid-vci-credentials.xml      |  13 ++
 .../plugin/openidvci/conf/openid-vci.properties    |   6 +
 .../shibboleth/plugin/openidvci/module.properties  |   4 +-
 .../plugin/oauth/security/DidJwkSupportTest.java   |  93 +++++++++
 ...mOutboundIssuerMetadataResponseMessageTest.java | 212 +++++++++++++++++++++
 17 files changed, 827 insertions(+), 190 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupport.java
new file mode 100644
index 0000000..29de09e
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupport.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.security;
+
+import java.security.PrivateKey;
+import java.security.PublicKey;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.crypto.SecretKey;
+
+import org.opensaml.security.credential.Credential;
+
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/**
+ * Support for naming the signing key of a token by the did:jwk it is issued
+ * under.
+ *
+ */
+public final class DidJwkSupport {
+
+    /** Prefix of a did:jwk identifier. */
+    @Nonnull
+    private static final String PREFIX = "did:jwk:";
+
+    /** Fragment naming the only verification method of a did:jwk document. */
+    @Nonnull
+    private static final String VERIFICATION_METHOD = "#0";
+
+    /** Constructor. */
+    private DidJwkSupport() {
+    }
+
+    /**
+     * Name the signing key of the parameters by the issuer of the token.
+     *
+     * A verifier resolves the key of a did:jwk issuer by the 'kid' header, which
+     * has to name the verification method of the document rather than the key
+     * itself. An issuer that is no did:jwk leaves the key named as it is.
+     *
+     * @param parameters parameters to name the key of
+     * @param issuer     issuer the token names
+     * @return parameters naming the key, or the given ones when there is nothing to
+     *         name it by
+     */
+    @Nonnull
+    public static SignatureSigningParameters nameKeyByIssuer(@Nonnull final SignatureSigningParameters parameters,
+            @Nullable final String issuer) {
+
+        final Credential credential = parameters.getSigningCredential();
+        if (issuer == null || !issuer.startsWith(PREFIX) || credential == null) {
+            return parameters;
+        }
+
+        final SignatureSigningParameters named = new SignatureSigningParameters();
+        named.setSignatureAlgorithm(parameters.getSignatureAlgorithm());
+        named.setSigningCredential(nameKey(credential, issuer + VERIFICATION_METHOD));
+        return named;
+    }
+
+    /**
+     * Copy a credential, naming its key.
+     *
+     * @param credential credential to copy
+     * @param keyName    name to give the key
+     * @return the copy
+     */
+    @Nonnull
+    private static Credential nameKey(@Nonnull final Credential credential, @Nonnull final String keyName) {
+
+        final BasicJWKCredential copy = new BasicJWKCredential();
+        copy.getKeyNames().add(keyName);
+        copy.setKid(keyName);
+
+        final PublicKey publicKey = credential.getPublicKey();
+        if (publicKey != null) {
+            copy.setPublicKey(publicKey);
+        }
+        final PrivateKey privateKey = credential.getPrivateKey();
+        if (privateKey != null) {
+            copy.setPrivateKey(privateKey);
+        }
+        final SecretKey secretKey = credential.getSecretKey();
+        if (secretKey != null) {
+            copy.setSecretKey(secretKey);
+        }
+        copy.setUsageType(credential.getUsageType());
+        copy.setEntityId(credential.getEntityId());
+        if (credential instanceof final JWKCredential jwk) {
+            copy.setAlgorithm(jwk.getAlgorithm());
+        }
+        return copy;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
index ce21ffb..976afb7 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
@@ -16,46 +16,37 @@
 
 package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
 
-import java.security.interfaces.ECPrivateKey;
-import java.util.List;
 import java.util.function.Function;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import org.geant.shibboleth.plugin.oauth.security.DidJwkSupport;
 import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
 import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
-import org.opensaml.security.credential.Credential;
 import org.slf4j.Logger;
 
-import com.nimbusds.jose.JOSEException;
-import com.nimbusds.jose.JOSEObjectType;
-import com.nimbusds.jose.JWSAlgorithm;
-import com.nimbusds.jose.JWSHeader;
-import com.nimbusds.jose.crypto.ECDSASigner;
-import com.nimbusds.jose.util.Base64;
-import com.nimbusds.jwt.SignedJWT;
-
 import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.impl.JWSTokenSigner;
+import net.shibboleth.oidc.security.jose.SignatureException;
 import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
 import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.logic.FunctionSupport;
 import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
  * Action that signs claims in {@link StatusListTokenContext#getClaims} and
  * stores the result to {@link StatusListTokenContext#setSignedToken}.
  *
- * Same as SignJWTCredential of the credentials flow, including support only for
- * EC family only. Sets the statuslist+jwt type header required of the token.
+ * Signing is done with the parameters of the status list signing configuration,
+ * which offers EC algorithms only. Sets the statuslist+jwt type header required
+ * of the token.
  */
 public class SignStatusListToken extends AbstractProfileAction {
 
@@ -78,10 +69,6 @@ public class SignStatusListToken extends AbstractProfileAction {
     @Nonnull
     private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
 
-    /** Strategy used to obtain the certificate chain for signing key. */
-    @Nonnull
-    private Function<SignatureSigningParameters, List<Base64>> certificateChainLookupStrategy;
-
     /** Strategy used to locate the status list token context. */
     @Nonnull
     private Function<ProfileRequestContext, StatusListTokenContext> statusListTokenContextLookupStrategy;
@@ -90,14 +77,6 @@ public class SignStatusListToken extends AbstractProfileAction {
     @NonnullAfterInit
     private SignatureSigningParameters signatureSigningParameters;
 
-    /** Signing credential. */
-    @NonnullAfterInit
-    private Credential signingCredential;
-
-    /** EC private key for signing. */
-    @NonnullAfterInit
-    private ECPrivateKey privateKey;
-
     /** Constructor. */
     public SignStatusListToken() {
         final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
@@ -109,8 +88,6 @@ public class SignStatusListToken extends AbstractProfileAction {
                 StatusListTokenContext.class).compose(new OutboundMessageContextLookup());
         assert tlcs != null;
         statusListTokenContextLookupStrategy = tlcs;
-
-        certificateChainLookupStrategy = FunctionSupport.constant(null);
     }
 
     /**
@@ -137,18 +114,6 @@ public class SignStatusListToken extends AbstractProfileAction {
                 "StatusListTokenContext lookup strategy cannot be null");
     }
 
-    /**
-     * Set the strategy used to obtain the certificate chain for the signing key.
-     *
-     * @param strategy lookup strategy
-     */
-    public void setCertificateChainLookupStrategy(
-            @Nonnull final Function<SignatureSigningParameters, List<Base64>> strategy) {
-        checkSetterPreconditions();
-        certificateChainLookupStrategy = Constraint.isNotNull(strategy,
-                "CertificateChainLookupStrategy lookup strategy cannot be null");
-    }
-
     /**
      * Get the signing parameters to apply.
      *
@@ -185,37 +150,23 @@ public class SignStatusListToken extends AbstractProfileAction {
             log.debug("{} no signature signing parameters available", getLogPrefix());
             return false;
         }
-        signingCredential = signatureSigningParameters.getSigningCredential();
-        if (signingCredential == null) {
+        if (signatureSigningParameters.getSigningCredential() == null) {
             log.debug("{} no signature signing credential available", getLogPrefix());
             return false;
         }
-        if (signingCredential.getPrivateKey() instanceof final ECPrivateKey ecKey) {
-            privateKey = ecKey;
-            return true;
-        }
-        log.error("{} No EC private key as signing parameter", getLogPrefix());
-        ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
-        return false;
+        return true;
     }
 
     /** {@inheritDoc} */
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
         try {
-            // For now we support only EC family.
-            final SignedJWT signedJWT = new SignedJWT(
-                    new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
-                            .x509CertChain(certificateChainLookupStrategy.apply(signatureSigningParameters))
-                            .type(new JOSEObjectType(TOKEN_TYPE))
-                            .keyID(CredentialConversionUtil.resolveKid(signingCredential)).build(),
-                    ctx.getClaims());
-
-            signedJWT.sign(new ECDSASigner(privateKey));
-            ctx.setSignedToken(signedJWT.serialize());
+            ctx.setSignedToken(new JWSTokenSigner(
+                    DidJwkSupport.nameKeyByIssuer(signatureSigningParameters, ctx.getClaims().getIssuer()))
+                            .sign(ctx.getClaims(), TOKEN_TYPE).serialize());
 
             log.debug("{} Signed status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
-        } catch (final JOSEException e) {
+        } catch (final SignatureException e) {
             log.error("{} Signing status list token failed", getLogPrefix(), e);
             ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
         }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/OpenIDVCIConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/OpenIDVCIConfiguration.java
index d77411f..4498346 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/OpenIDVCIConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/OpenIDVCIConfiguration.java
@@ -115,20 +115,23 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
     @Positive
     @Nonnull
     Duration getCredentialLifetime(@Nullable final ProfileRequestContext profileRequestContext);
-    
+
     /**
-     * Get the {@link SignatureValidationConfiguration} to be used for Credential Proof JWT signature validation.
+     * Get the {@link SignatureValidationConfiguration} to be used for Credential
+     * Proof JWT signature validation.
      * 
      * @param profileRequestContext current profile request context
      * 
      * @return the signature validation configuration to use
      */
-    @ConfigurationSetting(name="proofSignatureValidationConfiguration")
-    @Nullable SignatureValidationConfiguration getProofSignatureValidationConfiguration(
+    @ConfigurationSetting(name = "proofSignatureValidationConfiguration")
+    @Nullable
+    SignatureValidationConfiguration getProofSignatureValidationConfiguration(
             @Nullable final ProfileRequestContext profileRequestContext);
-    
+
     /**
-     * Get the {@link SignatureSigningConfiguration} used for signing issued Credentials.
+     * Get the {@link SignatureSigningConfiguration} used for signing issued
+     * Credentials.
      *
      * <p>
      * Configuration sets signing keys and algorithms deployment allows at all.
@@ -140,19 +143,22 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
      *
      * @return the signature signing configuration to use
      */
-    @ConfigurationSetting(name="credentialSignatureSigningConfiguration")
-    @Nullable SignatureSigningConfiguration getCredentialSignatureSigningConfiguration(
+    @ConfigurationSetting(name = "credentialSignatureSigningConfiguration")
+    @Nullable
+    SignatureSigningConfiguration getCredentialSignatureSigningConfiguration(
             @Nullable final ProfileRequestContext profileRequestContext);
 
     /**
-     * Get the {@link ClaimsValidator} to apply to Proof JWTs being validated by this profile.
+     * Get the {@link ClaimsValidator} to apply to Proof JWTs being validated by
+     * this profile.
      * 
      * @param profileRequestContext current profile request context
      * 
      * @return the validator to use
      */
-    @ConfigurationSetting(name="proofClaimsValidator")
-    @Nullable ClaimsValidator getProofClaimsValidator(@Nullable final ProfileRequestContext profileRequestContext);
+    @ConfigurationSetting(name = "proofClaimsValidator")
+    @Nullable
+    ClaimsValidator getProofClaimsValidator(@Nullable final ProfileRequestContext profileRequestContext);
 
     /**
      * Get the {@link Function} to create nonces to be used with Proof JWTs.
@@ -161,8 +167,9 @@ public interface OpenIDVCIConfiguration extends ConditionalProfileConfiguration
      * 
      * @return the nonce generator to use
      */
-    @ConfigurationSetting(name="proofNonceGenerator")
-    @Nullable Function<ProfileRequestContext, String> getProofNonceGenerator(
+    @ConfigurationSetting(name = "proofNonceGenerator")
+    @Nullable
+    Function<ProfileRequestContext, String> getProofNonceGenerator(
             @Nullable final ProfileRequestContext profileRequestContext);
 
 }
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
index e429639..7026d7f 100644
--- 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
@@ -16,10 +16,19 @@
 
 package org.geant.shibboleth.plugin.openidvci.config.impl;
 
+import java.util.function.Function;
+
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
 
 import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
+import net.shibboleth.shared.annotation.ConfigurationSetting;
 import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
 
 /**
  * Profile configuration for the endpoint publishing Credential Issuer metadata,
@@ -41,9 +50,59 @@ public class DefaultOpenIDVCIIssuerMetadataConfiguration extends AbstractInterce
     @NotEmpty
     public static final String PROFILE_ID = "http://geant.org/ns/profiles/openid/vci/issuer-metadata";
 
+    /** Signing of the published metadata. */
+    @Nonnull
+    private Function<ProfileRequestContext, SignatureSigningConfiguration>
+            issuerMetadataSignatureSigningConfigurationLookupStrategy;
+
     /** Constructor. */
     public DefaultOpenIDVCIIssuerMetadataConfiguration() {
         super(PROFILE_ID);
+        issuerMetadataSignatureSigningConfigurationLookupStrategy = FunctionSupport.constant(null);
+    }
+
+    /**
+     * Get the {@link SignatureSigningConfiguration} used for signing the published
+     * metadata.
+     *
+     * <p>
+     * Signed metadata is published only when this offers a signing credential.
+     * </p>
+     *
+     * @param profileRequestContext current profile request context
+     *
+     * @return the signature signing configuration to use
+     */
+    @ConfigurationSetting(name="issuerMetadataSignatureSigningConfiguration")
+    @Nullable
+    public SignatureSigningConfiguration getIssuerMetadataSignatureSigningConfiguration(
+            @Nullable final ProfileRequestContext profileRequestContext) {
+        return issuerMetadataSignatureSigningConfigurationLookupStrategy.apply(profileRequestContext);
+    }
+
+    /**
+     * Set the {@link SignatureSigningConfiguration} to sign the published metadata
+     * with.
+     *
+     * @param configuration configuration to use
+     *
+     */
+    public void setIssuerMetadataSignatureSigningConfiguration(
+            @Nullable final SignatureSigningConfiguration configuration) {
+        issuerMetadataSignatureSigningConfigurationLookupStrategy = FunctionSupport.constant(configuration);
+    }
+
+    /**
+     * Set a lookup strategy for the {@link SignatureSigningConfiguration} to sign
+     * the published metadata with.
+     *
+     * @param strategy lookup strategy
+     *
+     */
+    public void setIssuerMetadataSignatureSigningConfigurationLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, SignatureSigningConfiguration> strategy) {
+        issuerMetadataSignatureSigningConfigurationLookupStrategy = Constraint.isNotNull(strategy,
+                "Lookup strategy cannot be null");
     }
 
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/navigate/IssuerMetadataSignatureSigningConfigurationLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/navigate/IssuerMetadataSignatureSigningConfigurationLookupFunction.java
new file mode 100644
index 0000000..6502feb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/config/navigate/IssuerMetadataSignatureSigningConfigurationLookupFunction.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.config.navigate;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCIIssuerMetadataConfiguration;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
+import net.shibboleth.profile.config.ProfileConfiguration;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.profile.context.navigate.AbstractRelyingPartyLookupFunction;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * A function that obtains
+ * {@link DefaultOpenIDVCIIssuerMetadataConfiguration#getIssuerMetadataSignatureSigningConfiguration}
+ * if such a profile is available from a {@link RelyingPartyContext} obtained
+ * via a lookup function, by default a child of the
+ * {@link ProfileRequestContext}.
+ */
+public class IssuerMetadataSignatureSigningConfigurationLookupFunction
+        extends AbstractRelyingPartyLookupFunction<List<SignatureSigningConfiguration>> {
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public List<SignatureSigningConfiguration> apply(@Nullable final ProfileRequestContext input) {
+        final RelyingPartyContext rpc = getRelyingPartyContextLookupStrategy().apply(input);
+        if (rpc != null) {
+            final ProfileConfiguration pc = rpc.getProfileConfig();
+            if (pc instanceof DefaultOpenIDVCIIssuerMetadataConfiguration impc) {
+                final SignatureSigningConfiguration config = impc.getIssuerMetadataSignatureSigningConfiguration(input);
+                if (config != null) {
+                    return CollectionSupport.listOf(config);
+                }
+            }
+        }
+        return CollectionSupport.emptyList();
+    }
+}
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
index 733320a..e60c6f8 100644
--- 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
@@ -60,6 +60,7 @@ public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse
     public HTTPResponse toHTTPResponse() {
         final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
         httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+        httpResponse.setHeader("Vary", "Accept");
         httpResponse.setContent(content);
         return httpResponse;
     }
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/SignedCredentialIssuerMetadataSuccessResponse.java
similarity index 68%
copy from openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/CredentialIssuerMetadataSuccessResponse.java
copy to openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/SignedCredentialIssuerMetadataSuccessResponse.java
index 733320a..cb36807 100644
--- 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/SignedCredentialIssuerMetadataSuccessResponse.java
@@ -16,22 +16,20 @@
 
 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.jwt.SignedJWT;
 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">
+ * Response carrying the Credential Issuer metadata as signed metadata, as in
+ * <a href=
+ * "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-signed-metadata">
  * OpenID4VCI</a>.
  */
-public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse {
+public class SignedCredentialIssuerMetadataSuccessResponse implements SuccessResponse {
 
     /** Serialized metadata document. */
     @Nonnull
@@ -40,13 +38,10 @@ public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse
     /**
      * Constructor.
      *
-     * @param metadata members of the metadata document
-     *
-     * @throws JsonProcessingException if the members cannot be serialized
+     * @param metadata the signed metadata document
      */
-    public CredentialIssuerMetadataSuccessResponse(@Nonnull final Map<String, Object> metadata)
-            throws JsonProcessingException {
-        content = new ObjectMapper().writeValueAsString(metadata);
+    public SignedCredentialIssuerMetadataSuccessResponse(@Nonnull final SignedJWT metadata) {
+        content = metadata.serialize();
     }
 
     /** {@inheritDoc} */
@@ -59,7 +54,8 @@ public class CredentialIssuerMetadataSuccessResponse implements SuccessResponse
     @Override
     public HTTPResponse toHTTPResponse() {
         final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
-        httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+        httpResponse.setEntityContentType(ContentType.APPLICATION_JWT);
+        httpResponse.setHeader("Vary", "Accept");
         httpResponse.setContent(content);
         return httpResponse;
     }
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
index 9d2e788..a6c9c3a 100644
--- 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
@@ -16,19 +16,35 @@
 
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.function.Function;
+
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialIssuerMetadataSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.SignedCredentialIssuerMetadataSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
 import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
 import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
 import org.slf4j.Logger;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
 
+import jakarta.servlet.http.HttpServletRequest;
 import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.impl.JWSTokenSigner;
+import net.shibboleth.oidc.security.jose.SignatureException;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.component.ComponentInitializationException;
@@ -37,12 +53,26 @@ import net.shibboleth.shared.primitive.LoggerFactory;
 import net.shibboleth.shared.resolver.ResolverException;
 
 /**
- * Action forming {@link CredentialIssuerMetadataSuccessResponse} from the
- * document the attached {@link CredentialIssuerMetadataResolver} resolves.
+ * Action forming {@link CredentialIssuerMetadataSuccessResponse}, or
+ * {@link SignedCredentialIssuerMetadataSuccessResponse} when the request names
+ * signed metadata and signing is configured, from the document the attached
+ * {@link CredentialIssuerMetadataResolver} resolves.
  *
  */
 public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAction {
 
+    /** Media type of signed metadata. */
+    @Nonnull
+    private static final String SIGNED_MEDIA_TYPE = "application/jwt";
+
+    /** Media type of unsigned metadata. */
+    @Nonnull
+    private static final String UNSIGNED_MEDIA_TYPE = "application/json";
+
+    /** Type of the signed metadata document. */
+    @Nonnull
+    private static final String SIGNED_DOCUMENT_TYPE = "openidvci-issuer-metadata+jwt";
+
     /** Class logger. */
     @Nonnull
     private final Logger log = LoggerFactory.getLogger(FormOutboundIssuerMetadataResponseMessage.class);
@@ -51,10 +81,28 @@ public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAc
     @NonnullAfterInit
     private CredentialIssuerMetadataResolver metadataResolver;
 
+    /**
+     * Strategy used to locate the {@link SecurityParametersContext} to sign with.
+     */
+    @Nonnull
+    private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+    /** Lifetime of signed metadata. */
+    @Nullable
+    private Duration signedMetadataLifetime;
+
     /** Metadata to publish. */
     @NonnullBeforeExec
     private CredentialIssuerMetadata metadata;
 
+    /** Constructor. */
+    public FormOutboundIssuerMetadataResponseMessage() {
+        final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+                SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+        assert splcs != null;
+        securityParametersLookupStrategy = splcs;
+    }
+
     /**
      * Set the resolver of the metadata that is being published.
      *
@@ -65,6 +113,29 @@ public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAc
         metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
     }
 
+    /**
+     * Set the strategy used to locate the {@link SecurityParametersContext} to sign
+     * with.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setSecurityParametersLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+        checkSetterPreconditions();
+        securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+                "SecurityParametersContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the lifetime of signed metadata.
+     *
+     * @param lifetime lifetime to use, or null to publish it without one
+     */
+    public void setSignedMetadataLifetime(@Nullable final Duration lifetime) {
+        checkSetterPreconditions();
+        signedMetadataLifetime = lifetime;
+    }
+
     /** {@inheritDoc} */
     @Override
     protected void doInitialize() throws ComponentInitializationException {
@@ -107,6 +178,16 @@ public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAc
     @Override
     protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
 
+        if (prefersSignedMetadata()) {
+            final SignatureSigningParameters parameters = resolveSigningParameters(profileRequestContext);
+            if (parameters != null) {
+                publishSigned(profileRequestContext, parameters);
+                return;
+            }
+            log.debug("{} Signed metadata was asked for but no signing credential is configured, "
+                    + "publishing the document unsigned", getLogPrefix());
+        }
+
         try {
             profileRequestContext.ensureOutboundMessageContext()
                     .setMessage(new CredentialIssuerMetadataSuccessResponse(metadata.getMembers()));
@@ -120,6 +201,101 @@ public class FormOutboundIssuerMetadataResponseMessage extends AbstractProfileAc
                 metadata.getCredentialIssuer(), metadata.getMembers().keySet());
     }
 
+    /**
+     * Publish the document as signed metadata.
+     *
+     * @param profileRequestContext current profile request context
+     * @param parameters            parameters to sign with
+     */
+    private void publishSigned(@Nonnull final ProfileRequestContext profileRequestContext,
+            @Nonnull final SignatureSigningParameters parameters) {
+
+        final Instant now = Instant.now();
+        final JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder();
+        metadata.getMembers().forEach(claims::claim);
+        claims.subject(metadata.getCredentialIssuer().getValue());
+        claims.issueTime(Date.from(now));
+        final Duration lifetime = signedMetadataLifetime;
+        if (lifetime != null) {
+            claims.expirationTime(Date.from(now.plus(lifetime)));
+        }
+
+        final SignedJWT document;
+        try {
+            document = new JWSTokenSigner(parameters).sign(claims.build(), SIGNED_DOCUMENT_TYPE);
+        } catch (final SignatureException e) {
+            log.error("{} Could not sign Credential Issuer metadata", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+            return;
+        }
+
+        profileRequestContext.ensureOutboundMessageContext()
+                .setMessage(new SignedCredentialIssuerMetadataSuccessResponse(document));
+
+        log.debug("{} Publishing signed Credential Issuer metadata of {}, members {}", getLogPrefix(),
+                metadata.getCredentialIssuer(), metadata.getMembers().keySet());
+    }
+
+    /**
+     * Get whether the request names signed metadata and not the unsigned document.
+     *
+     * Only an exact media type names a representation, a wildcard names neither.
+     *
+     * @return whether to publish signed metadata
+     */
+    private boolean prefersSignedMetadata() {
+
+        final HttpServletRequest request = getHttpServletRequest();
+        if (request == null) {
+            return false;
+        }
+
+        final String accept = request.getHeader("Accept");
+        if (accept == null || accept.isBlank()) {
+            return false;
+        }
+
+        return names(accept, SIGNED_MEDIA_TYPE) && !names(accept, UNSIGNED_MEDIA_TYPE);
+    }
+
+    /**
+     * Get whether an Accept header names a media type.
+     *
+     * @param accept    header to read
+     * @param mediaType media type to look for
+     * @return whether the header names the media type
+     */
+    private boolean names(@Nonnull final String accept, @Nonnull final String mediaType) {
+
+        for (final String entry : accept.split(",")) {
+            if (mediaType.equalsIgnoreCase(entry.split(";")[0].trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Resolve the parameters to sign the document with.
+     *
+     * @param profileRequestContext current profile request context
+     * @return the parameters, or null when signing is not configured
+     */
+    @Nullable
+    private SignatureSigningParameters resolveSigningParameters(
+            @Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final SecurityParametersContext context = securityParametersLookupStrategy.apply(profileRequestContext);
+        if (context == null) {
+            return null;
+        }
+        final SignatureSigningParameters parameters = context.getSignatureSigningParameters();
+        if (parameters == null || parameters.getSigningCredential() == null) {
+            return null;
+        }
+        return parameters;
+    }
+
     /**
      * Check the document has a member, building an event when it does not.
      *
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
index cf8bae7..76107a6 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
@@ -23,8 +23,7 @@ import java.util.function.Function;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
-import java.security.interfaces.ECPrivateKey;
-
+import org.geant.shibboleth.plugin.oauth.security.DidJwkSupport;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
 import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
@@ -34,33 +33,24 @@ import org.opensaml.profile.action.ActionSupport;
 import org.opensaml.profile.action.EventIds;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
-import org.opensaml.security.credential.Credential;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.impl.JWSTokenSigner;
+import net.shibboleth.oidc.security.jose.SignatureException;
 import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
 import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
 import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.logic.FunctionSupport;
 
-import com.nimbusds.jose.JOSEException;
-import com.nimbusds.jose.JOSEObjectType;
-import com.nimbusds.jose.JWSAlgorithm;
-import com.nimbusds.jose.JWSHeader;
-import com.nimbusds.jose.crypto.ECDSASigner;
-import com.nimbusds.jose.util.Base64;
-import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.JWTClaimsSet;
 
 /**
  * Action that signs all jwt verifiable credentials
  * {@link CredentialsContext#getJWTCredentials} storing the result to
  * {@link CredentialsContext#setSignedCredential}
- * 
- * TODO: Use generic JWSTokenSigner and modify it to support the case.
  */
 public class SignJWTCredential extends AbstractOIDCResponseAction {
 
@@ -86,25 +76,12 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
     @NonnullAfterInit
     private SignatureSigningParameters signatureSigningParameters;
 
-    /** Signing credential. */
-    @NonnullAfterInit
-    private Credential signingCredential;
-
-    /** EC private key for signing. */
-    @NonnullAfterInit
-    private ECPrivateKey privateKey;
-
-    /** Strategy used to obtain the certificate chain for signing key. */
-    @Nonnull
-    private Function<SignatureSigningParameters, List<Base64>> certificateChainLookupStrategy;
-
     public SignJWTCredential() {
         final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
                 SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
         assert splcs != null;
         securityParametersLookupStrategy = splcs;
         issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
-        certificateChainLookupStrategy = FunctionSupport.constant(null);
     }
 
     /**
@@ -130,18 +107,6 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
         issuerLookupStrategy = Constraint.isNotNull(strategy, "IssuerLookupStrategy lookup strategy cannot be null");
     }
 
-    /**
-     * Set the strategy used to locate the issuer value to use.
-     * 
-     * @param strategy lookup strategy
-     */
-    public void setCertificateChainLookupStrategy(
-            @Nonnull Function<SignatureSigningParameters, List<Base64>> strategy) {
-        checkSetterPreconditions();
-        certificateChainLookupStrategy = Constraint.isNotNull(strategy,
-                "CertificateChainLookupStrategy lookup strategy cannot be null");
-    }
-
     /**
      * Get the signing parameters to apply.
      * 
@@ -176,18 +141,11 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
             log.debug("{} no signature signing parameters available", getLogPrefix());
             return false;
         }
-        signingCredential = signatureSigningParameters.getSigningCredential();
-        if (signingCredential == null) {
+        if (signatureSigningParameters.getSigningCredential() == null) {
             log.debug("{} no signature signing credential available", getLogPrefix());
             return false;
         }
-        if (signingCredential.getPrivateKey() instanceof final ECPrivateKey ecKey) {
-            privateKey = ecKey;
-            return true;
-        }
-        log.error("{} No EC private key as signing parameter", getLogPrefix());
-        ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
-        return false;
+        return true;
     }
 
     /** {@inheritDoc} */
@@ -199,34 +157,21 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
             return;
         }
-        try {
-            //
-            // For now we support only EC family
-            final ECDSASigner signer = new ECDSASigner(privateKey);
-            final List<String> credentials = new ArrayList<>();
-            ctx.getJWTCredentials().forEach(credential -> {
-                SignedJWT signedJWT = new SignedJWT(
-                        new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
-                                .x509CertChain(certificateChainLookupStrategy.apply(signatureSigningParameters))
-                                .type(new JOSEObjectType(ctx.getCredentialConfiguration().getFormat()))
-                                .keyID(CredentialConversionUtil.resolveKid(signingCredential)).build(),
-                        credential);
-
-                try {
-                    signedJWT.sign(signer);
-                } catch (JOSEException e) {
-                    log.error("{} Signing credential failed", getLogPrefix(), e);
-                    ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
-                    return;
-                }
-                credentials.add(signedJWT.serialize());
-            });
-            ctx.setSignedCredential(credentials);
-            log.info("Signed {} credentials", credentials.size());
-        } catch (JOSEException e) {
-            log.error("{} Error occurred while parsing objects {}", getLogPrefix(), e);
-            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        final String format = ctx.getCredentialConfiguration().getFormat();
+        final List<String> credentials = new ArrayList<>();
+        for (final JWTClaimsSet credential : ctx.getJWTCredentials()) {
+            try {
+                credentials.add(new JWSTokenSigner(
+                        DidJwkSupport.nameKeyByIssuer(signatureSigningParameters, credential.getIssuer()))
+                        .sign(credential, format).serialize());
+            } catch (final SignatureException e) {
+                log.error("{} Signing credential failed", getLogPrefix(), e);
+                ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+                return;
+            }
         }
+        ctx.setSignedCredential(credentials);
+        log.info("Signed {} credentials", credentials.size());
     }
 
 }
\ No newline at end of file
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
index 3d5fc83..3d95bc9 100644
--- 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
@@ -17,10 +17,31 @@
   <bean id="InitializeUnverifiedRelyingPartyContext"
         class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
 
+  <bean id="PopulateIssuerMetadataSignatureSigningParameters"
+        class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureSigningParameters"
+        scope="prototype"
+        c:strategy-ref="shibboleth.MessageContextLookup.Outbound">
+    <property name="configurationLookupStrategy">
+      <bean lazy-init="true"
+            class="org.geant.shibboleth.plugin.openidvci.config.navigate.IssuerMetadataSignatureSigningConfigurationLookupFunction" />
+    </property>
+    <!-- Metadata is signed with the issuer's own key, there is no client to have one. -->
+    <property name="signatureSigningParametersResolver">
+      <bean class="net.shibboleth.oidc.security.jose.impl.BasicSignatureSigningParametersResolver" />
+    </property>
+    <property name="securityParametersContextLookupStrategy">
+      <bean parent="shibboleth.Functions.Compose"
+            c:g-ref="shibboleth.oidc.ChildLookupOrCreate.JWTSecurityParameters"
+            c:f-ref="shibboleth.MessageContextLookup.Outbound" />
+    </property>
+  </bean>
+
   <bean id="FormOutboundMessage"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundIssuerMetadataResponseMessage"
         scope="prototype"
-        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier"
+        p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}"
+        p:signedMetadataLifetime="#{'%{openidvci.issuerMetadata.signedLifetime:}'.trim().isEmpty() ? null : T(java.time.Duration).parse('%{openidvci.issuerMetadata.signedLifetime:}'.trim())}" />
 
   <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
         scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
@@ -32,6 +53,8 @@
       <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}" />
+        <entry key="#{T(org.opensaml.profile.action.EventIds).INVALID_SEC_CFG}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
       </map>
     </property>
   </bean>
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
index 850637d..3840e91 100644
--- 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
@@ -4,11 +4,6 @@
       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" />
@@ -17,6 +12,7 @@
     <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
     <evaluate expression="SelectRelyingPartyConfiguration" />
     <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="PopulateIssuerMetadataSignatureSigningParameters" />
     <evaluate expression="'proceed'" />
 
     <transition on="proceed" to="BuildResponseMessage" />
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 38b07a9..7d055b4 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
@@ -54,7 +54,8 @@
     -->
     <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}" />
+        p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
+        p:issuerMetadataSignatureSigningConfiguration-ref="openidvci.issuerMetadata.SigningConfiguration" />
 
     <bean id="OAuth.StatusList" lazy-init="true"
         class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListConfiguration"
@@ -106,14 +107,6 @@
         </property>
     </bean>
 
-    <!--
-    Signing configuration of issued Credentials. Keys are OP's response signing credentials,
-    unless deployer defines list bean "openidvci.SigningCredentials". Then only those are
-    used. Own key must be published also, see conf/openid-vci-credentials.xml.
-
-    Only EC algorithms are offered, signing actions support nothing else. Used algorithm is
-    narrowed still per Credential Configuration by credential_signing_alg_values_supported.
-    -->
     <bean id="openidvci.SigningConfiguration"
         parent="shibboleth.oidc.BasicSignatureSigningConfiguration"
         p:signingCredentials="#{getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}">
@@ -129,18 +122,12 @@
         </property>
     </bean>
 
-    <!--
-    Signing configuration of Status List Tokens. Kept separate from Credential one, status
-    list is general purpose mechanism and does not serve only Verifiable Credentials. Keys
-    default still to same ones.
-
-    Status List Token names same issuer as Credentials referring to it. While issuer is
-    did:jwk, issuer IS the signing key. Own key here means Credential refers to status list
-    of some other issuer. Separate the keys only when openidvci.issuer names real issuer.
-    -->
     <bean id="oauth.statuslist.SigningConfiguration" parent="openidvci.SigningConfiguration"
         p:signingCredentials="#{getObject('oauth.statuslist.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}" />
 
+    <bean id="openidvci.issuerMetadata.SigningConfiguration" parent="openidvci.SigningConfiguration"
+        p:signingCredentials="#{getObject('openidvci.issuerMetadata.SigningCredentials')}" />
+
     <bean id="openidvci.ProofSignatureValidationConfiguration"
         parent="shibboleth.oidc.BasicSignatureValidationConfiguration"
         p:signatureTrustEngine-ref="openidvci.TokenKeyTrustEngineForProofJWT"/>
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 5377ffa..27dc5cd 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
@@ -42,4 +42,17 @@
     </util:list>
     -->
 
+    <!--
+    Signing key of Credential Issuer metadata. Must be EC key, signing action supports
+    nothing else. 
+    -->
+    <!--
+    <bean id="openidvci.issuerMetadata.DefaultESSigningCredential" parent="shibboleth.JWKCredential"
+        p:resource="%{idp.signing.openidvci.issuerMetadata.es.key}" />
+
+    <util:list id="openidvci.issuerMetadata.SigningCredentials">
+        <ref bean="openidvci.issuerMetadata.DefaultESSigningCredential" />
+    </util:list>
+    -->
+
 </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 6b93c88..baf5c2b 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
@@ -13,6 +13,10 @@ idp.signing.openidvci.es.key = %{idp.home}/credentials/openid-vci-signing-es.jwk
 # in conf/openid-vci-credentials.xml. Must be EC key.
 idp.signing.oauth.statuslist.es.key = %{idp.home}/credentials/oauth-statuslist-signing-es.jwk
 
+# Signing key of Credential Issuer metadata. Used only if you enable the credential of the
+# same name in conf/openid-vci-credentials.xml. Must be EC key.
+idp.signing.openidvci.issuerMetadata.es.key = %{idp.home}/credentials/openid-vci-issuer-metadata-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.
@@ -30,6 +34,8 @@ idp.signing.oauth.statuslist.es.key = %{idp.home}/credentials/oauth-statuslist-s
 #openidvci.issuerMetadata.resolver.values = openidvci.issuerMetadata.DefaultDynamicValueResolvers
 #openidvci.issuerMetadata.minRefreshDelay = PT5M
 #openidvci.issuerMetadata.maxRefreshDelay = PT4H
+# Lifetime written as 'exp' of signed metadata. Leave unset to publish it without one.
+#openidvci.issuerMetadata.signedLifetime = 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
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 8608bf1..b616ad0 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
@@ -29,7 +29,9 @@ 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. \
+kept in that file, they are published from metadata/verifiable-credentials.json. Signed metadata \
+is served in addition to the unsigned document, to a wallet asking for application/jwt, only once \
+you enable the metadata signing key in conf/openid-vci-credentials.xml. \
 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/test/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupportTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupportTest.java
new file mode 100644
index 0000000..70cc2f5
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/security/DidJwkSupportTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.security;
+
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+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.oidc.jwa.support.SignatureConstants;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/**
+ * Unit tests for {@link DidJwkSupport}.
+ */
+public class DidJwkSupportTest {
+
+    private static final String DID = "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IngiLCJ5IjoieSJ9";
+
+    private ECKey key;
+
+    private SignatureSigningParameters parameters;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        key = new ECKeyGenerator(Curve.P_256).keyID("signingvci").generate();
+
+        final BasicJWKCredential credential = new BasicJWKCredential();
+        credential.setPublicKey(key.toECPublicKey());
+        credential.setPrivateKey(key.toECPrivateKey());
+        credential.setKid(key.getKeyID());
+
+        parameters = new SignatureSigningParameters();
+        parameters.setSigningCredential(credential);
+        parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+    }
+
+    @Test
+    public void testNamedByDidJwk() throws Exception {
+        final SignatureSigningParameters named = DidJwkSupport.nameKeyByIssuer(parameters, DID);
+
+        Assert.assertEquals(CredentialConversionUtil.resolveKid(named.getSigningCredential()), DID + "#0");
+        Assert.assertEquals(named.getSignatureAlgorithm(), SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+        Assert.assertEquals(named.getSigningCredential().getPublicKey(), key.toECPublicKey());
+        Assert.assertEquals(named.getSigningCredential().getPrivateKey(), key.toECPrivateKey());
+    }
+
+    /** The key of the given parameters is left as it is. */
+    @Test
+    public void testGivenParametersUntouched() throws Exception {
+        DidJwkSupport.nameKeyByIssuer(parameters, DID);
+
+        Assert.assertEquals(CredentialConversionUtil.resolveKid(parameters.getSigningCredential()), "signingvci");
+    }
+
+    @Test(dataProvider = "notDidJwk")
+    public void testIssuerIsNoDidJwk(final String issuer) throws Exception {
+        Assert.assertSame(DidJwkSupport.nameKeyByIssuer(parameters, issuer), parameters);
+    }
+
+    @DataProvider(name = "notDidJwk")
+    public Object[][] notDidJwk() {
+        return new Object[][] { { null }, { "" }, { "https://example.org" }, { "did:web:example.org" }, };
+    }
+
+    @Test
+    public void testNoSigningCredential() throws Exception {
+        final SignatureSigningParameters empty = new SignatureSigningParameters();
+
+        Assert.assertSame(DidJwkSupport.nameKeyByIssuer(empty, DID), empty);
+    }
+
+}
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
index 0557811..25eab5d 100644
--- 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
@@ -16,11 +16,18 @@
 
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
 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.messaging.impl.SignedCredentialIssuerMetadataSuccessResponse;
 import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
 import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
 import org.opensaml.messaging.context.MessageContext;
@@ -28,15 +35,30 @@ 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.DataProvider;
 import org.testng.annotations.Test;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.jose.crypto.ECDSAVerifier;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
 
+import jakarta.servlet.http.HttpServletRequest;
 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.jwa.support.SignatureConstants;
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.primitive.NonnullSupplier;
 import net.shibboleth.shared.resolver.ResolverException;
+import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.webflow.execution.RequestContext;
 
 /**
@@ -48,11 +70,17 @@ public class FormOutboundIssuerMetadataResponseMessageTest {
 
     private RequestContext requestCtx;
 
+    private MockHttpServletRequest httpRequest;
+
+    private ECKey signingKey;
+
     @BeforeMethod
     protected void setUp() throws Exception {
         requestCtx = new RequestContextBuilder().buildRequestContext();
         profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
         profileRequestCtx.setOutboundMessageContext(new MessageContext());
+        httpRequest = new MockHttpServletRequest();
+        signingKey = new ECKeyGenerator(Curve.P_256).keyID("metadata-signing").generate();
     }
 
     private Map<String, Object> members() {
@@ -67,12 +95,45 @@ public class FormOutboundIssuerMetadataResponseMessageTest {
 
     private FormOutboundIssuerMetadataResponseMessage actionOf(final CredentialIssuerMetadata metadata)
             throws Exception {
+        return actionOf(metadata, null);
+    }
+
+    private FormOutboundIssuerMetadataResponseMessage actionOf(final CredentialIssuerMetadata metadata,
+            final Duration lifetime) throws Exception {
         final FormOutboundIssuerMetadataResponseMessage action = new FormOutboundIssuerMetadataResponseMessage();
         action.setMetadataResolver(new StubResolver(metadata, false));
+        action.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+            public HttpServletRequest get() {
+                return httpRequest;
+            }
+        });
+        action.setSignedMetadataLifetime(lifetime);
         action.initialize();
         return action;
     }
 
+    private void configureSigning(final String algorithm, final PrivateKey key) throws Exception {
+        final BasicJWKCredential credential = new BasicJWKCredential();
+        credential.setPublicKey(signingKey.toPublicKey());
+        credential.setPrivateKey(key);
+        credential.setKid(signingKey.getKeyID());
+        credential.getKeyNames().add(signingKey.getKeyID());
+
+        final SignatureSigningParameters parameters = new SignatureSigningParameters();
+        parameters.setSigningCredential(credential);
+        parameters.setSignatureAlgorithm(algorithm);
+
+        profileRequestCtx.ensureOutboundMessageContext().ensureSubcontext(SecurityParametersContext.class)
+                .setSignatureSigningParameters(parameters);
+    }
+
+    private SignedJWT published() throws Exception {
+        final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+        Assert.assertTrue(message instanceof SignedCredentialIssuerMetadataSuccessResponse);
+        return SignedJWT.parse(
+                ((SignedCredentialIssuerMetadataSuccessResponse) message).toHTTPResponse().getContent());
+    }
+
     @Test
     public void testSuccess() throws Exception {
         final FormOutboundIssuerMetadataResponseMessage action = actionOf(CredentialIssuerMetadata.parse(members()));
@@ -122,6 +183,157 @@ public class FormOutboundIssuerMetadataResponseMessageTest {
                 EventIds.IO_ERROR);
     }
 
+    @Test
+    public void testUnsignedCarriesVaryAccept() throws Exception {
+        final FormOutboundIssuerMetadataResponseMessage action = actionOf(CredentialIssuerMetadata.parse(members()));
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+        Assert.assertEquals(
+                ((CredentialIssuerMetadataSuccessResponse) message).toHTTPResponse().getHeaderMap().get("Vary"),
+                List.of("Accept"));
+    }
+
+    @Test(dataProvider = "unsignedAccept")
+    public void testPublishedUnsigned(final String accept) throws Exception {
+        if (accept != null) {
+            httpRequest.addHeader("Accept", accept);
+        }
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256, signingKey.toPrivateKey());
+
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx));
+
+        Assert.assertTrue(profileRequestCtx.ensureOutboundMessageContext()
+                .getMessage() instanceof CredentialIssuerMetadataSuccessResponse);
+    }
+
+    @DataProvider(name = "unsignedAccept")
+    public Object[][] unsignedAccept() {
+        return new Object[][] {
+            {null},
+            {""},
+            {"*/*"},
+            {"application/*"},
+            {"application/json"},
+            {"application/json, application/jwt"},
+            {"text/html"},
+        };
+    }
+
+    @Test(dataProvider = "signedAccept")
+    public void testPublishedSigned(final String accept) throws Exception {
+        httpRequest.addHeader("Accept", accept);
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256, signingKey.toPrivateKey());
+
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx));
+
+        Assert.assertTrue(profileRequestCtx.ensureOutboundMessageContext()
+                .getMessage() instanceof SignedCredentialIssuerMetadataSuccessResponse);
+    }
+
+    @DataProvider(name = "signedAccept")
+    public Object[][] signedAccept() {
+        return new Object[][] {
+            {"application/jwt"},
+            {"APPLICATION/JWT"},
+            {"application/jwt, text/html"},
+            {"application/jwt;q=0.9"},
+            {" application/jwt ; charset=utf-8 "},
+        };
+    }
+
+    @Test
+    public void testSignedRequestedWithoutSigningConfigured() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx));
+
+        Assert.assertTrue(profileRequestCtx.ensureOutboundMessageContext()
+                .getMessage() instanceof CredentialIssuerMetadataSuccessResponse);
+    }
+
+    @Test
+    public void testSignedDocument() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256, signingKey.toPrivateKey());
+
+        final Instant before = Instant.now().truncatedTo(ChronoUnit.SECONDS);
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx));
+        final SignedJWT document = published();
+
+        Assert.assertEquals(document.getHeader().getType().toString(), "openidvci-issuer-metadata+jwt");
+        Assert.assertEquals(document.getHeader().getAlgorithm().getName(),
+                SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+        Assert.assertEquals(document.getHeader().getKeyID(), signingKey.getKeyID());
+        Assert.assertNull(document.getHeader().getX509CertChain());
+
+        Assert.assertTrue(document.verify(new ECDSAVerifier(signingKey.toECPublicKey())));
+
+        Assert.assertEquals(document.getJWTClaimsSet().getSubject(), "https://example.org");
+        Assert.assertFalse(document.getJWTClaimsSet().getIssueTime().before(Date.from(before)));
+        Assert.assertNull(document.getJWTClaimsSet().getExpirationTime());
+        Assert.assertNull(document.getJWTClaimsSet().getIssuer());
+
+        for (final Map.Entry<String, Object> member : members().entrySet()) {
+            Assert.assertEquals(document.getJWTClaimsSet().getClaim(member.getKey()), member.getValue(),
+                    "Member " + member.getKey());
+        }
+    }
+
+    @Test
+    public void testSignedDocumentLifetime() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256, signingKey.toPrivateKey());
+
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members()), Duration.ofHours(4)).execute(requestCtx));
+        final SignedJWT document = published();
+
+        Assert.assertEquals(
+                document.getJWTClaimsSet().getExpirationTime().getTime()
+                        - document.getJWTClaimsSet().getIssueTime().getTime(),
+                Duration.ofHours(4).toMillis());
+    }
+
+    @Test
+    public void testSignedResponseIsJwt() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256, signingKey.toPrivateKey());
+
+        ActionTestingSupport.assertProceedEvent(
+                actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx));
+
+        final HTTPResponse response = ((SignedCredentialIssuerMetadataSuccessResponse) profileRequestCtx
+                .ensureOutboundMessageContext().getMessage()).toHTTPResponse();
+        Assert.assertEquals(response.getStatusCode(), 200);
+        Assert.assertEquals(response.getEntityContentType().getType(), ContentType.APPLICATION_JWT.getType());
+        Assert.assertEquals(response.getHeaderMap().get("Vary"), List.of("Accept"));
+    }
+
+    @Test
+    public void testSigningKeyIsNotEC() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+        configureSigning(SignatureConstants.ALGO_ID_SIGNATURE_ES_256,
+                KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate());
+
+        ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx),
+                EventIds.INVALID_SEC_CFG);
+    }
+
+    @Test
+    public void testSigningConfigurationOffersNoAlgorithm() throws Exception {
+        httpRequest.addHeader("Accept", "application/jwt");
+        configureSigning(null, signingKey.toPrivateKey());
+
+        ActionTestingSupport.assertEvent(actionOf(CredentialIssuerMetadata.parse(members())).execute(requestCtx),
+                EventIds.INVALID_SEC_CFG);
+    }
+
     private static class StubResolver implements CredentialIssuerMetadataResolver {
 
         private final CredentialIssuerMetadata metadata;

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


More information about the commits mailing list