[java-idp-plugin-vci] 01/01: Review did/document flow: intercept, counter, headers, tests, key rollover steps in README

Codeberg noreply at shibboleth.net
Fri Nov 6 08:12:35 UTC 2026


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

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

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

commit 7ad60726854a4b35299445cfb3c4d188ba5ea58d
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Fri Nov 6 10:12:00 2026 +0200

    Review did/document flow: intercept, counter, headers, tests, key rollover steps in README
---
 README.md                                          |  78 ++++---
 .../impl/DefaultMetadataDocumentConfiguration.java |   5 +
 .../FormOutboundDidDocumentResponseMessage.java    |  33 +++
 .../idp/flows/did/document/document-beans.xml      |  13 +-
 .../idp/flows/did/document/document-flow.xml       |   6 +-
 .../plugin/openidvci/conf/openid-vci.properties    |   7 +
 .../DefaultMetadataDocumentConfigurationTest.java  |   7 +-
 ...FormOutboundDidDocumentResponseMessageTest.java | 242 +++++++++++++++++++++
 8 files changed, 353 insertions(+), 38 deletions(-)

diff --git a/README.md b/README.md
index b7c832a..b5d7afa 100644
--- a/README.md
+++ b/README.md
@@ -3817,36 +3817,57 @@ Full file: [static/oauth-authorization-server.json](docs/example/step-5/static/o
 
 ### Step 6: Rolling the signing key
 
-This applies to both setups, the DIIP setup of Steps 1 to 4 and the HAIP setup of Step 5. What a
-key change means depends on the issuer ID of the credential. The configuration is the
-same for all:
-
-1. The new key goes **first** in **openidvci.SigningCredentials**. The first key that fits the
-   algorithm signs everything new.
-2. The old key stays in the list, after the new one, until every credential signed with it has
-   expired. See the table below what happens if it is removed earlier.
-3. Where a verifier reads the key from something you publish, publish the new key before you
-   start signing with it.
-
-A key that is in the list but does not sign is still published in the did:web document and in
-the Entity Configuration. For did:jwk credentials it also still signs the Status List Tokens
-of the credentials it signed.
-
-| Identifier | What happens when the key changes |
-|---|---|
-| `did:jwk` | The identifier is the key, so it changes. New credentials have a new issuer. Old credentials can still be verified on their own because they contain the key. However, a verifier that trusts the issuer by its identifier must be told about the new identifier. The Status List URL of an old credential contains the old key’s thumbprint, and the list is signed with that key. If the old key is removed from the list, the Status List Token for the old credentials is no longer served [...]
-| `did:web` | The identifier stays. The did:web document lists every key of the list, each by its name, so the new key needs a name of its own. Old credentials have the `kid` of the old key and it stays in the document as long as it is in the list. |
-| URL, no chain | The identifier stays. The key is found by `kid` in the OP’s keyset. Add the new key to **shibboleth.oidc.SigningCredentialsToPublish** before using it for signing, and keep the old key there until its credentials have expired. |
-| URL, with chain | The identifier stays. The credential carries its certificate chain, and the verifier trusts the root. You do not need to publish a new leaf certificate issued by the same CA. The Status List of URL-based credentials is not tied to a key; it is signed with the first key in **openidvci.status-list.SigningCredentials**. |
-| OpenID Federation | The Entity Configuration publishes the keys of the list. Same as did:web. |
-| mdoc | Same as URL with a certificate chain. |
+This applies to both setups:
+- the DIIP setup (Steps 1–4)
+- the HAIP setup (Step 5)
+
+#### Publish the new key before using it
+
+You need to publish the new key before using it to sign anything if you are offering
+credentials whose issuer is of type did:web, OpenID Federation or URL without a chain.
+
+The steps depend on the issuer type:
+
+- did:web and OpenID Federation: add the new key to **openidvci.SigningCredentials**, after the
+  current key. Then wait until verifiers can no longer have a cached copy of the published list
+  that does not include the new key.
+  - For did:web, wait until the document's `max-age` period has passed. This is configured in
+    `openidvci.didDocument.cacheControl`.
+  - For OpenID Federation, wait until the Entity Configuration cache period has passed.
+- URL issuer without a chain: add the new key to **shibboleth.oidc.SigningCredentialsToPublish**,
+  and keep the old key there until its credentials have expired.
+
+#### Put the new key first
+
+Put the new key first in **openidvci.SigningCredentials**. If you added it after the current key
+in the previous step, move it. When signing a new credential, the plugin uses the first key in
+the list that supports the required algorithm.
+
+#### Keep the old key until all credentials have expired
 
-Therefore, `did:jwk` is not a good choice for a deployment that rotates keys and revokes credentials.
-Use it for testing and for credentials that expire before the signing key is rotated.
+Keep the old key in the list, after the new key, until all credentials signed with the old key
+have expired. It no longer signs new credentials, but it is still published in the did:web
+document and in the Entity Configuration, and for did:jwk credentials it still signs the Status
+List Tokens of the credentials it signed.
 
-Status List Tokens may be signed with a key of their own, **openidvci.status-list.SigningCredentials**,
-as in [Step 5](#certificate-chain). While any credential is did:jwk, keep the keys that signed
-them in that list too.
+If the old key is removed earlier, did:web and OpenID Federation credentials signed with it can
+no longer be verified, and the Status List Tokens of did:jwk credentials signed with it are no
+longer served.
+
+#### Status List Tokens
+
+Change the signing key for Status List Tokens in the same way, using
+**openidvci.status-list.SigningCredentials**. Status List Tokens may have a key of their own, as in
+[Step 5](#certificate-chain). While any credential is did:jwk, keep the keys that signed them in
+that list too.
+
+#### Apply the configuration changes
+
+Changes to a key list take effect after running the following command:
+
+```bash
+bin/reload-service.sh -id shibboleth.RelyingPartyResolverService
+```
 
 ## Requesting a credential by configuration or by identifier
 
@@ -4962,6 +4983,7 @@ The file comes with the plugin and is loaded automatically.
 | `openidvci.authorizationServer.resolver` | Bean ID | **openidvci.DefaultAuthorizationServerMetadataResolver** | Resolver of the document. |
 | `openidvci.authorizationServer.minRefreshDelay`, `…maxRefreshDelay` | Duration | `PT5M`, `PT4H` | Bounds of re-reading the file. |
 | `openidvci.authorizationServer.cacheControl` | String | `max-age=3600` | Cache-Control of the response. |
+| `openidvci.didDocument.cacheControl` | String | `max-age=3600` | Cache-Control of the did:web document. |
 
 #### Signing keys
 
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 7c52e15..10795b6 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
@@ -53,6 +53,11 @@ public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwa
     @NotEmpty
     public static final String PROFILE_ID_DID_DOCUMENT = "http://geant.org/ns/profiles/did/document";
 
+    /** did:web document profile counter name. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_COUNTER_DID_DOCUMENT = "net.shibboleth.idp.profiles.did.document";
+
     /**
      * Constructor.
      *
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 383a702..957cb07 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,6 +27,8 @@ import java.util.function.Function;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
+import jakarta.servlet.http.HttpServletResponse;
+
 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;
@@ -35,19 +37,23 @@ import org.opensaml.profile.context.ProfileRequestContext;
 import org.opensaml.security.credential.Credential;
 import org.slf4j.Logger;
 
+import com.google.common.net.HttpHeaders;
 import com.nimbusds.jose.jwk.JWK;
 
 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.oidc.security.CredentialConversionUtil;
 import net.shibboleth.oidc.security.credential.JWKCredential;
 import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
 
 /**
  * Action publishing the did:web document of this deployment.
@@ -115,6 +121,11 @@ public class FormOutboundDidDocumentResponseMessage extends AbstractProfileActio
     @Nonnull
     private Function<ProfileRequestContext, List<SignatureSigningConfiguration>> configurationLookupStrategy;
 
+    /** Value of the Cache-Control header. */
+    @Nonnull
+    @NotEmpty
+    private String cacheControl;
+
     /** The did:web identifier of this deployment. */
     @NonnullAfterInit
     private String didWeb;
@@ -122,6 +133,17 @@ public class FormOutboundDidDocumentResponseMessage extends AbstractProfileActio
     /** Constructor. */
     public FormOutboundDidDocumentResponseMessage() {
         configurationLookupStrategy = new DidDocumentSignatureSigningConfigurationLookupFunction();
+        cacheControl = "max-age=3600";
+    }
+
+    /**
+     * 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");
     }
 
     /**
@@ -177,6 +199,17 @@ public class FormOutboundDidDocumentResponseMessage extends AbstractProfileActio
             return;
         }
 
+        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, FormOutboundMetadataDocumentResponseMessage.NOSNIFF);
+        response.setHeader(HttpHeaders.CONTENT_SECURITY_POLICY,
+                FormOutboundMetadataDocumentResponseMessage.CONTENT_SECURITY_POLICY);
+
         final Map<String, Object> document = new LinkedHashMap<>();
         document.put(CONTEXT, CONTEXTS);
         document.put(ID, didWeb);
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml
index df29dce..014ed88 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml
@@ -16,13 +16,18 @@
   <bean id="openidvci.loggingId" class="java.lang.String"
         c:_0="%{openidvci.logging.did-document:DID.Document}"/>
 
+  <util:constant id="shibboleth.metrics.ProfileCounter"
+        static-field="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_DID_DOCUMENT"/>
+
   <bean id="InitializeUnverifiedRelyingPartyContext"
         class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
 
   <bean id="OpenIDVCIFormOutboundMessage"
         class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundDidDocumentResponseMessage"
         scope="prototype"
-        p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}" />
+        p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier"
+        p:cacheControl="%{openidvci.didDocument.cacheControl:max-age=3600}" />
 
   <bean id="OpenIDVCIBuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
         scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
@@ -30,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/did/document/document-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-flow.xml
index b4ebaa6..dc7920e 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-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/did/document/document-beans.xml" />
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 c85bcb7..dd03190 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
@@ -117,6 +117,12 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 # Cache-Control of the response
 #openidvci.authorizationServer.cacheControl = max-age=3600
 
+# Settings for the did-document flow
+# Flow is available at /did/document, usually it should be wired from /.well-known/did.json.
+# Cache-Control of the response. A verifier may use a cached document for this long, so wait at
+# least this long after adding a signing key before signing with it.
+#openidvci.didDocument.cacheControl = max-age=3600
+
 # Settings for stating this deployment as a Credential Issuer in OpenID Federation. Active
 # only when conf/openid-vci-oidfed.xml is imported into conf/global.xml. Set to false to
 # state federation_entity entirely in the federation module's own metadata file instead of
@@ -145,6 +151,7 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 #openidvci.logging.issuer-metadata = OpenID.VCI.IssuerMetadata
 #openidvci.logging.jwt-vc-issuer = OAuth.JWTVCIssuer
 #openidvci.logging.authorization-server = OAuth.AuthorizationServer
+#openidvci.logging.did-document = DID.Document
 #openidvci.logging.status-list.list = OAuth.StatusList
 #openidvci.logging.status-list.revoke = OAuth.StatusList.Revoke
 #openidvci.logging.status-list.assignments = OAuth.StatusList.Assignments
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
index a911591..1f3c7d8 100644
--- 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
@@ -51,8 +51,11 @@ public class DefaultMetadataDocumentConfigurationTest {
     public void testCountersAreDistinct() {
         Assert.assertEquals(DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_AUTHORIZATION_SERVER,
                 "net.shibboleth.idp.profiles.oauth.authorization-server");
-        Assert.assertNotEquals(DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_AUTHORIZATION_SERVER,
-                DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_JWT_VC_ISSUER);
+        Assert.assertEquals(DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_DID_DOCUMENT,
+                "net.shibboleth.idp.profiles.did.document");
+        Assert.assertEquals(Set.of(DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_JWT_VC_ISSUER,
+                DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_AUTHORIZATION_SERVER,
+                DefaultMetadataDocumentConfiguration.PROFILE_COUNTER_DID_DOCUMENT).size(), 3);
     }
 
     @Test
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessageTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessageTest.java
new file mode 100644
index 0000000..2c8c08c
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessageTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.BasicCredential;
+import org.opensaml.security.credential.Credential;
+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.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+import jakarta.servlet.http.HttpServletResponse;
+import net.minidev.json.JSONArray;
+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.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
+import net.shibboleth.oidc.security.jose.impl.BasicSignatureSigningConfiguration;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+
+/**
+ * Unit tests for {@link FormOutboundDidDocumentResponseMessage}.
+ */
+public class FormOutboundDidDocumentResponseMessageTest {
+
+    private static final String DID = "did:web:issuer.example.org";
+
+    private RequestContext requestCtx;
+
+    private ProfileRequestContext profileRequestCtx;
+
+    private MockHttpServletResponse httpResponse;
+
+    private Credential credentialKey;
+
+    private Credential opKey;
+
+    private Credential statusListKey;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        requestCtx = new RequestContextBuilder().buildRequestContext();
+        profileRequestCtx = new WebflowRequestContextProfileRequestContextLookup().apply(requestCtx);
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+        httpResponse = new MockHttpServletResponse();
+
+        credentialKey = x509Like("signingvci-x509");
+        statusListKey = x509Like("statuslist-x509");
+
+        final ECKey jwk = new ECKeyGenerator(Curve.P_256).keyID("signingvci").generate();
+        final BasicJWKCredential op = new BasicJWKCredential();
+        op.setPublicKey(jwk.toPublicKey());
+        op.setPrivateKey(jwk.toPrivateKey());
+        op.setKid(jwk.getKeyID());
+        op.setAlgorithm(JWSAlgorithm.ES256);
+        opKey = op;
+    }
+
+    private static Credential x509Like(final String name) throws Exception {
+        final BasicCredential credential =
+                new BasicCredential(new ECKeyGenerator(Curve.P_256).generate().toPublicKey());
+        credential.getKeyNames().add(name);
+        return credential;
+    }
+
+    private static SignatureSigningConfiguration configOf(final Credential... credentials) {
+        final BasicSignatureSigningConfiguration config = new BasicSignatureSigningConfiguration();
+        config.setSigningCredentials(List.of(credentials));
+        return config;
+    }
+
+    private FormOutboundDidDocumentResponseMessage actionOf(final String issuer,
+            final List<SignatureSigningConfiguration> configs, final boolean withResponse) {
+        final FormOutboundDidDocumentResponseMessage action = new FormOutboundDidDocumentResponseMessage();
+        action.setDeploymentIssuer(issuer);
+        action.setConfigurationLookupStrategy(prc -> configs);
+        if (withResponse) {
+            action.setHttpServletResponseSupplier(new NonnullSupplier<>() {
+                public HttpServletResponse get() {
+                    return httpResponse;
+                }
+            });
+        }
+        return action;
+    }
+
+    private JSONObject published() throws Exception {
+        final Object message = profileRequestCtx.ensureOutboundMessageContext().getMessage();
+        Assert.assertTrue(message instanceof JSONSuccessResponse);
+        final HTTPResponse response = ((JSONSuccessResponse) message).toHTTPResponse();
+        Assert.assertEquals(response.getStatusCode(), HTTPResponse.SC_OK);
+        return (JSONObject) JSONValue.parse(response.getContent());
+    }
+
+    @Test
+    public void testDocument() throws Exception {
+        final FormOutboundDidDocumentResponseMessage action = actionOf("https://issuer.example.org/idp",
+                List.of(configOf(credentialKey, opKey), configOf(statusListKey, credentialKey, opKey)), true);
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        final JSONObject document = published();
+        Assert.assertEquals(document.get("@context"),
+                List.of("https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"));
+        Assert.assertEquals(document.get("id"), DID);
+
+        final List<String> ids = new ArrayList<>();
+        for (final Object entry : (JSONArray) document.get("verificationMethod")) {
+            final Map<?, ?> method = (Map<?, ?>) entry;
+            ids.add((String) method.get("id"));
+            Assert.assertEquals(method.get("type"), "JsonWebKey2020");
+            Assert.assertEquals(method.get("controller"), DID);
+            final Map<?, ?> key = (Map<?, ?>) method.get("publicKeyJwk");
+            Assert.assertEquals(key.get("kty"), "EC");
+            Assert.assertNull(key.get("d"));
+            Assert.assertEquals(key.get("alg"), method.get("id").equals(DID + "#signingvci") ? "ES256" : null);
+        }
+        Assert.assertEquals(ids, List.of(DID + "#signingvci-x509", DID + "#signingvci", DID + "#statuslist-x509"));
+        Assert.assertEquals(document.get("assertionMethod"), ids);
+    }
+
+    @Test
+    public void testPortIsEncoded() throws Exception {
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org:8443", List.of(configOf(credentialKey)), true);
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        Assert.assertEquals(published().get("id"), "did:web:issuer.example.org%3A8443");
+    }
+
+    @Test
+    public void testUnnamedKeyLeftOut() throws Exception {
+        final Credential unnamed = new BasicCredential(new ECKeyGenerator(Curve.P_256).generate().toPublicKey());
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org", List.of(configOf(unnamed, credentialKey)), true);
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        Assert.assertEquals(published().get("assertionMethod"), List.of(DID + "#signingvci-x509"));
+    }
+
+    @Test
+    public void testDefaultHeaders() throws Exception {
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org", List.of(configOf(credentialKey)), 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");
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org", List.of(configOf(credentialKey)), true);
+        action.setCacheControl(" max-age=60 ");
+        action.initialize();
+
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+
+        Assert.assertEquals(httpResponse.getHeaders("Cache-Control"), List.of("max-age=60"));
+    }
+
+    @Test
+    public void testEmptyCacheControl() {
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org", List.of(configOf(credentialKey)), true);
+
+        Assert.assertThrows(ConstraintViolationException.class, () -> action.setCacheControl(" "));
+    }
+
+    @Test
+    public void testNoKeys() throws Exception {
+        final FormOutboundDidDocumentResponseMessage action = actionOf("https://issuer.example.org", List.of(), true);
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.IO_ERROR);
+        Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+        Assert.assertNull(httpResponse.getHeader("Cache-Control"));
+    }
+
+    @Test
+    public void testNoHttpServletResponse() throws Exception {
+        final FormOutboundDidDocumentResponseMessage action =
+                actionOf("https://issuer.example.org", List.of(configOf(credentialKey)), false);
+        action.initialize();
+
+        ActionTestingSupport.assertEvent(action.execute(requestCtx), IdPEventIds.INVALID_PROFILE_CONFIG);
+        Assert.assertNull(profileRequestCtx.ensureOutboundMessageContext().getMessage());
+    }
+
+    @Test(expectedExceptions = ComponentInitializationException.class)
+    public void testIssuerWithoutHost() throws Exception {
+        actionOf("urn:example:issuer", List.of(configOf(credentialKey)), true).initialize();
+    }
+
+}

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


More information about the commits mailing list