[java-idp-plugin-vci] 03/03: Create common shell for all supported credential types.

Codeberg noreply at shibboleth.net
Fri Jan 2 12:26:25 UTC 2026


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

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

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

commit 702ba8e79151fc4dd9d9502d488b51189a491cd1
Author: jlauros <janne.lauros at csc.fi>
AuthorDate: Fri Jan 2 14:26:11 2026 +0200

    Create common shell for all supported credential types.
---
 .../messaging/context/CredentialsContext.java      |  25 ++-
 .../openidvci/profile/impl/AddCredentialShell.java | 224 +++++++++++++++++++++
 .../FormOutboundCredentialsResponseMessage.java    |  60 +-----
 .../openid/vci/credentials/credentials-beans.xml   |   5 +-
 .../openid/vci/credentials/credentials-flow.xml    |   7 +
 5 files changed, 270 insertions(+), 51 deletions(-)

diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 13ca331..c2ab09a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -25,6 +25,7 @@ import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferReque
 import org.opensaml.messaging.context.BaseContext;
 
 import com.nimbusds.jose.JWSObject;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
 
 /**
  * Subcontext for /credentials - end point. This context appears as a subcontext
@@ -42,10 +43,14 @@ public class CredentialsContext extends BaseContext {
     @Nullable
     private String credentialIdentifier;
 
-    /** Validated proofs of wallet . */
+    /** Validated proofs of wallet. */
     @Nullable
     private List<JWSObject> proofs;
 
+    /** Credential claims. */
+    @Nullable
+    private List<ClaimsSet> credentialClaimsSet;
+
     /**
      * Get validated proofs of wallet.
      * 
@@ -122,4 +127,22 @@ public class CredentialsContext extends BaseContext {
         credentialIdentifier = identifier;
     }
 
+    /**
+     * Get credential claims.
+     * 
+     * @return Credential claims
+     */
+    public List<ClaimsSet> getCredentialClaimsSet() {
+        return credentialClaimsSet;
+    }
+
+    /**
+     * Set credential claims.
+     * 
+     * @param credentialClaimsSet Credential claims
+     */
+    public void setCredentialClaimsSet(List<ClaimsSet> credentialClaimsSet) {
+        this.credentialClaimsSet = credentialClaimsSet;
+    }
+
 }
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
new file mode 100644
index 0000000..6443aab
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.profile.config.ProfileConfiguration;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.openidvci.config.OpenIDVCIConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.oauth2.sdk.id.Audience;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
+
+/**
+ * Action that creates an Array of {@link ClaimsSet} shells for Credential(s),
+ * and sets it to work context {@link CredentialsContext} located under
+ * {@link ProfileRequestContext#getInboundMessageContext()}.
+ * 
+ * Shell is initialized with claims common to all credentials: 'iss', 'aud',
+ * 'iat', 'exp', 'validFrom', 'validUntil' and 'vct'. Final set of claims yet to
+ * be defined.
+ * 
+ * Each proof will have own shell. In such cases also 'jwk' claim is set.
+ */
+public class AddCredentialShell extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(AddCredentialShell.class);
+
+    /** Strategy used to obtain the response issuer value. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, String> issuerLookupStrategy;
+
+    /** Issuer ID to populate into Issuer element. */
+    @Nullable
+    private String issuerId;
+
+    /**
+     * Strategy used to locate the {@link RelyingPartyContext} associated with a
+     * given {@link ProfileRequestContext}.
+     */
+    @Nonnull
+    private Function<ProfileRequestContext, RelyingPartyContext> relyingPartyContextLookupStrategy;
+
+    /** The RelyingPartyContext to operate on. */
+    @Nullable
+    private RelyingPartyContext rpCtx;
+
+    /** Lifetime of credential. */
+    private Duration expiration;
+
+    /**
+     * Credential offer context.
+     */
+    @NonnullAfterInit
+    private CredentialsContext ctx;
+
+    /** Constructor. */
+    public AddCredentialShell() {
+        relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
+        issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the {@link RelyingPartyContext} associated
+     * with a given {@link ProfileRequestContext}.
+     * 
+     * @param strategy strategy used to locate the {@link RelyingPartyContext}
+     *                 associated with a given {@link ProfileRequestContext}
+     */
+    public void setRelyingPartyContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, RelyingPartyContext> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        relyingPartyContextLookupStrategy = Constraint.isNotNull(strategy,
+                "RelyingPartyContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the issuer value to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setIssuerLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        issuerLookupStrategy = Constraint.isNotNull(strategy, "IssuerLookupStrategy lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (issuerLookupStrategy == null) {
+            throw new ComponentInitializationException("Issuer lookup strategy cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
+        if (rpCtx == null) {
+            log.debug("{} No relying party context associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+        final ProfileConfiguration pc = rpCtx.getProfileConfig();
+        if (pc instanceof OpenIDVCIConfiguration) {
+            expiration = ((OpenIDVCIConfiguration) pc).getCredentialLifetime(profileRequestContext);
+        } else {
+            log.error("{} No OpenID VCI profile configuration associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+
+        issuerId = issuerLookupStrategy.apply(profileRequestContext);
+        if (issuerId == null) {
+            log.error("{} Unable to determine issuer to check audience", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+
+        ctx = profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
+        if (ctx == null) {
+            log.debug("{} No credentials context associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        List<ClaimsSet> shells = new ArrayList<>();
+        if (ctx.getProofs() != null && !ctx.getProofs().isEmpty()) {
+            ctx.getProofs().forEach(proof -> {
+                ClaimsSet shell = createShell();
+                Map<String, Object> cnfKid = new HashMap<>();
+                try {
+                    cnfKid.put("jwk",
+                            new ObjectMapper().readValue(proof.getHeader().getJWK().toJSONString(), Object.class));
+                    shell.setClaim("cnf", cnfKid);
+                } catch (JsonProcessingException e1) {
+                    log.error("{} Parsing failed", getLogPrefix(), e1);
+                    ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                    return;
+                }
+                shells.add(shell);
+
+            });
+        } else {
+            shells.add(createShell());
+        }
+        ctx.setCredentialClaimsSet(shells);
+    }
+
+    /**
+     * Creates shell without proof.
+     * 
+     * @return Shell without proof
+     */
+    private ClaimsSet createShell() {
+        ClaimsSet shell = new ClaimsSet();
+        shell.setIssuer(new Issuer(issuerId));
+        shell.setAudience(new Audience(rpCtx.getRelyingPartyId()));
+        ZonedDateTime now = ZonedDateTime.now();
+        shell.setClaim("iat", now.toEpochSecond());
+        shell.setClaim("exp", now.toEpochSecond() + expiration.toSeconds());
+        shell.setClaim("validFrom", DateTimeFormatter.ISO_DATE_TIME.format(now));
+        shell.setClaim("validUntil", DateTimeFormatter.ISO_DATE_TIME.format(now.plusSeconds(expiration.getSeconds())));
+        shell.setClaim("vct", ctx.getCredentialIdentifier());
+        return shell;
+    }
+
+}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
index 7642474..8279494 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundCredentialsResponseMessage.java
@@ -17,7 +17,6 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -27,7 +26,6 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 import java.security.interfaces.ECPrivateKey;
-import java.time.Instant;
 
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
@@ -50,7 +48,6 @@ import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.logic.FunctionSupport;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JOSEObjectType;
 import com.nimbusds.jose.JWSAlgorithm;
@@ -60,6 +57,7 @@ import com.nimbusds.jose.util.Base64;
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.JWTClaimsSet.Builder;
 import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.ParseException;
 
 /**
  * Action forming {@link CredentialSuccessResponse}
@@ -195,52 +193,16 @@ public class FormOutboundCredentialsResponseMessage extends AbstractOIDCResponse
             credential.getRequestedCredential().forEach(claim -> claims.put(claim.getPath().get(0), claim.getValue()));
             SelectiveDisclosureClaimSetUtil sdClaims = new SelectiveDisclosureClaimSetUtil(claims);
 
-            // cnfKid should be picked from request proof key section.
             List<String> credentials = new ArrayList<>();
-            // Map<String, String> cnfKid = null;
-            if (ctx.getProofs() != null && !ctx.getProofs().isEmpty()) {
-                ctx.getProofs().forEach(proof -> {
-                    Map<String, Object> cnfKid = new HashMap<>();
-                    try {
-                        cnfKid.put("jwk",
-                                new ObjectMapper().readValue(proof.getHeader().getJWK().toJSONString(), Object.class));
-                    } catch (JsonProcessingException e1) {
-                        // TODO Auto-generated catch block
-                        e1.printStackTrace();
-                    }
-                    // TODO: vct is the credential configuration id in SD JWT. Here we use however
-                    // "instance" specific
-                    // identifier that happes to be in our case derived from it. We need to get the
-                    // real configuration id here.
-                    Builder build = new JWTClaimsSet.Builder().claim("vct", ctx.getCredentialIdentifier().split("_")[0])
-                            .claim("iss", issuerLookupStrategy.apply(profileRequestContext))
-                            // Just to pass happy path test.
-                            .claim("exp", Instant.now().getEpochSecond() + 3600).claim("_sd", sdClaims.get_sd())
-                            .claim("_sd_alg", sdClaims.get_alg()).claim("cnf", cnfKid).issueTime(new Date());
-                    JWTClaimsSet claimsSet = build.build();
-                    SignedJWT signedJWT = new SignedJWT(
-                            new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
-                                    .x509CertChain(certificateChainLookupStrategy.apply(signatureSigningParameters))
-                                    .type(new JOSEObjectType("dc+sd-jwt")).keyID("signing").build(),
-                            claimsSet);
-                    try {
-                        signedJWT.sign(signer);
-                    } catch (JOSEException e) {
-                        // TODO Auto-generated catch block
-                        e.printStackTrace();
-                    }
-                    String v15 = signedJWT.serialize();
-                    // Add the plain text claims
-                    v15 += "~" + sdClaims.getFormattedDisclosures() + "~";
-                    credentials.add(v15);
-
-                });
-            } else {
-                Builder build = new JWTClaimsSet.Builder().claim("vct", ctx.getCredentialIdentifier())
-                        // Just to pass happy path test.
-                        .claim("exp", Instant.now().getEpochSecond() + 3600)
-                        .claim("iss", issuerLookupStrategy.apply(profileRequestContext)).claim("_sd", sdClaims.get_sd())
-                        .claim("_sd_alg", sdClaims.get_alg()).issueTime(new Date());
+            ctx.getCredentialClaimsSet().forEach(cred -> {
+                Builder build = null;
+                try {
+                    build = new JWTClaimsSet.Builder(cred.toJWTClaimsSet()).claim("_sd", sdClaims.get_sd())
+                            .claim("_sd_alg", sdClaims.get_alg());
+                } catch (ParseException e1) {
+                    // TODO Auto-generated catch block
+                    e1.printStackTrace();
+                }
                 JWTClaimsSet claimsSet = build.build();
                 SignedJWT signedJWT = new SignedJWT(
                         new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
@@ -257,7 +219,7 @@ public class FormOutboundCredentialsResponseMessage extends AbstractOIDCResponse
                 // Add the plain text claims
                 v15 += "~" + sdClaims.getFormattedDisclosures() + "~";
                 credentials.add(v15);
-            }
+            });
             CredentialSuccessResponse response = new CredentialSuccessResponse(credentials);
             log.info("Setting response as {}", response.toOffer());
             profileRequestContext.ensureOutboundMessageContext().setMessage(response);
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index f75e960..c01f354 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -69,8 +69,11 @@
                 c:g-ref="shibboleth.oidc.ChildLookupOrCreate.JWTSecurityParameters"
                 c:f-ref="shibboleth.MessageContextLookup.Outbound" />
         </property>
-    </bean>
+  </bean>
     
+  <bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
+        scope="prototype" />
+          
   <bean id="FormOutboundMessage" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundCredentialsResponseMessage"
         scope="prototype" p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" >
         <property name="securityParametersLookupStrategy">
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
index 4e13778..3d3870d 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
@@ -31,6 +31,13 @@
   <action-state id="ResumeAfterDoDPoPProofValidation">
     <evaluate expression="PopulateCredentialsSignatureSigningParameters" />
     <evaluate expression="'proceed'"/>
+    <transition on="proceed" to="BuildResponse"/>
+  </action-state>
+  
+  
+  <action-state id="BuildResponse">
+    <evaluate expression="AddCredentialShell" />
+    <evaluate expression="'proceed'"/>
     <transition on="proceed" to="BuildResponseMessage"/>
   </action-state>
 

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


More information about the commits mailing list