From 32bc180e4e68d32e7f82bc3c7f64fd9954a9f69d Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Oct 2024 20:09:50 -0400 Subject: [PATCH 1/5] Add `step.issueRequests` feature. --- CHANGELOG.md | 13 +++++ lib/helpers.js | 36 ++++++++++++- lib/issue.js | 95 ++++++++++++++++++++++++++++------ lib/oid4/oid4vci.js | 27 +++------- lib/oid4/oid4vp.js | 11 ++-- lib/vcapi.js | 11 ++-- schemas/bedrock-vc-workflow.js | 45 ++++++++++++---- 7 files changed, 174 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb52ae..d0eb52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # bedrock-vc-delivery ChangeLog +## 6.3.0 - 2024-10-dd + +### Added +- Add `issueRequests` feature for expressing parameters for issuing VCs + in a particular step. The `issueRequest` value must be an array, with + each element containing parameters for issuing a VC. The parameters + must minimally include a credential template ID or index that + references a credential template from the associated workflow. The + parameters may optionally specify alternative variables to use when + evaluating the template, either via an object or a string, where + the string includes the name of a variable from the workflow's + main `variables`. + ## 6.2.0 - 2024-10-02 ### Changed diff --git a/lib/helpers.js b/lib/helpers.js index 1e04274..933d9bc 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -19,11 +19,16 @@ const ALLOWED_ERROR_KEYS = [ ]; export async function evaluateTemplate({ - workflow, exchange, typedTemplate + workflow, exchange, typedTemplate, variables } = {}) { // run jsonata compiler; only `jsonata` template type is supported and this // assumes only this template type will be passed in const {template} = typedTemplate; + variables = variables ?? getTemplateVariables({workflow, exchange}); + return jsonata(template).evaluate(variables, variables); +} + +export function getTemplateVariables({workflow, exchange} = {}) { const {variables = {}} = exchange; // always include `globals` as keyword for self-referencing exchange info variables.globals = { @@ -38,7 +43,7 @@ export async function evaluateTemplate({ id: exchange.id } }; - return jsonata(template).evaluate(variables, variables); + return variables; } export function getWorkflowId({routePrefix, localId} = {}) { @@ -207,6 +212,33 @@ export async function unenvelopePresentation({ return {presentation, ...result}; } +export async function validateStep({step} = {}) { + // FIXME: use `ajv` and do JSON schema check + if(Object.keys(step).length === 0) { + throw new BedrockError('Empty exchange step detected.', { + name: 'DataError', + details: {httpStatusCode: 500, public: true} + }); + } + if(step.issueRequests !== undefined && !Array.isArray(step.issueRequests)) { + throw new BedrockError( + 'Invalid "issueRequests" in step.', { + name: 'DataError', + details: {httpStatusCode: 500, public: true} + }); + } + // use of `jwtDidProofRequest` and `openId` together is prohibited + const {jwtDidProofRequest, openId} = step; + if(jwtDidProofRequest && openId) { + throw new BedrockError( + 'Invalid workflow configuration; only one of ' + + '"jwtDidProofRequest" and "openId" is permitted in a step.', { + name: 'DataError', + details: {httpStatusCode: 500, public: true} + }); + } +} + function _getEnvelope({envelope, format}) { const isString = typeof envelope === 'string'; if(isString) { diff --git a/lib/issue.js b/lib/issue.js index c8168f6..dc20bdb 100644 --- a/lib/issue.js +++ b/lib/issue.js @@ -1,43 +1,115 @@ /*! * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. */ +import * as bedrock from '@bedrock/core'; import { evaluateTemplate, + getTemplateVariables, getWorkflowIssuerInstances, getZcapClient } from './helpers.js'; import {createPresentation} from '@digitalbazaar/vc'; +const {util: {BedrockError}} = bedrock; + export async function issue({ - workflow, exchange, format = 'application/vc' + workflow, exchange, step, format = 'application/vc' } = {}) { // use any templates from workflow and variables from exchange to produce // credentials to be issued; issue via the configured issuer instance - const verifiableCredential = []; const {credentialTemplates = []} = workflow; if(!credentialTemplates || credentialTemplates.length === 0) { // nothing to issue return {response: {}}; } - // evaluate template - const issueRequests = await Promise.all(credentialTemplates.map( - typedTemplate => evaluateTemplate({workflow, exchange, typedTemplate}))); + // generate all issue requests for current step in exchange + const issueRequests = await _createIssueRequests({ + workflow, exchange, step, credentialTemplates + }); // issue all VCs const vcs = await _issue({workflow, issueRequests, format}); - verifiableCredential.push(...vcs); // generate VP to return VCs const verifiablePresentation = createPresentation(); // FIXME: add any encrypted VCs to VP // add any issued VCs to VP - if(verifiableCredential.length > 0) { - verifiablePresentation.verifiableCredential = verifiableCredential; + if(vcs.length > 0) { + verifiablePresentation.verifiableCredential = vcs; } return {response: {verifiablePresentation}, format}; } +async function _createIssueRequests({ + workflow, exchange, step, credentialTemplates +}) { + // if step does not define `issueRequests`, then use all for templates for + // backwards compatibility + let params; + if(!step.issueRequests) { + params = credentialTemplates.map(typedTemplate => ({typedTemplate})); + } else { + // resolve all issue requests params in parallel + const variables = getTemplateVariables({workflow, exchange}); + params = await Promise.all(step.issueRequests.map(async r => { + // find the typed template to use + let typedTemplate; + if(r.credentialTemplateIndex !== undefined) { + typedTemplate = credentialTemplates[r.credentialTemplateIndex]; + } else if(r.credentialTemplateId !== undefined) { + typedTemplate = credentialTemplates.find( + t => t.id === r.credentialTemplateId); + } + if(typedTemplate === undefined) { + throw new BedrockError( + 'Credential template ' + + `"${r.credentialTemplateIndex ?? r.credentialTemplateId}" ` + + 'not found.', { + name: 'DataError', + details: {httpStatusCode: 500, public: true} + }); + } + + // allow different variables to be specified for the typed template + let vars = variables; + if(r.variables !== undefined) { + if(typeof r.variables === 'string') { + vars = variables[r.variables]; + } else { + vars = r.variables; + } + if(!(vars && typeof vars === 'object')) { + throw new BedrockError( + `Issue request variables "${r.variables}" not found or invalid.`, { + name: 'DataError', + details: {httpStatusCode: 500, public: true} + }); + } + } + return { + typedTemplate, + variables: { + // always include globals but allow local override + globals: variables.globals, + ...r.variables + } + }; + })); + } + + // evaluate all issue requests + return Promise.all(params.map(({typedTemplate, variables}) => + evaluateTemplate({workflow, exchange, typedTemplate, variables}))); +} + +function _getIssueZcap({workflow, zcaps, format}) { + const issuerInstances = getWorkflowIssuerInstances({workflow}); + const {zcapReferenceIds: {issue: issueRefId}} = issuerInstances.find( + ({supportedFormats}) => supportedFormats.includes(format)); + return zcaps[issueRefId]; +} + async function _issue({workflow, issueRequests, format} = {}) { // create zcap client for issuing VCs const {zcapClient, zcaps} = await getZcapClient({workflow}); @@ -74,10 +146,3 @@ async function _issue({workflow, issueRequests, format} = {}) { return issuedVCs; } - -function _getIssueZcap({workflow, zcaps, format}) { - const issuerInstances = getWorkflowIssuerInstances({workflow}); - const {zcapReferenceIds: {issue: issueRefId}} = issuerInstances.find( - ({supportedFormats}) => supportedFormats.includes(format)); - return zcaps[issueRefId]; -} diff --git a/lib/oid4/oid4vci.js b/lib/oid4/oid4vci.js index a69a921..c61c30d 100644 --- a/lib/oid4/oid4vci.js +++ b/lib/oid4/oid4vci.js @@ -4,7 +4,7 @@ import * as bedrock from '@bedrock/core'; import * as exchanges from '../exchanges.js'; import { - deepEqual, evaluateTemplate, getWorkflowIssuerInstances + deepEqual, evaluateTemplate, getWorkflowIssuerInstances, validateStep } from '../helpers.js'; import {importJWK, SignJWT} from 'jose'; import {checkAccessToken} from '@bedrock/oauth2-verifier'; @@ -427,33 +427,18 @@ async function _processExchange({ }); // process exchange step if present + let step; const currentStep = exchange.step; if(currentStep) { - let step = workflow.steps[exchange.step]; + step = workflow.steps[exchange.step]; if(step.stepTemplate) { // generate step from the template; assume the template type is // `jsonata` per the JSON schema step = await evaluateTemplate( {workflow, exchange, typedTemplate: step.stepTemplate}); - if(Object.keys(step).length === 0) { - throw new BedrockError('Could not create exchange step.', { - name: 'DataError', - details: {httpStatusCode: 500, public: true} - }); - } - } - - // do late workflow configuration validation - const {jwtDidProofRequest, openId} = step; - // use of `jwtDidProofRequest` and `openId` together is prohibited - if(jwtDidProofRequest && openId) { - throw new BedrockError( - 'Invalid workflow configuration; only one of ' + - '"jwtDidProofRequest" and "openId" is permitted in a step.', { - name: 'DataError', - details: {httpStatusCode: 500, public: true} - }); } + await validateStep({step}); + const {jwtDidProofRequest} = step; // check to see if step supports OID4VP during OID4VCI if(step.openId) { @@ -518,7 +503,7 @@ async function _processExchange({ // replay attack detected) after exchange has been marked complete // issue VCs - return issue({workflow, exchange, format}); + return issue({workflow, exchange, step, format}); } catch(e) { if(e.name === 'InvalidStateError') { throw e; diff --git a/lib/oid4/oid4vp.js b/lib/oid4/oid4vp.js index f8260b7..8311124 100644 --- a/lib/oid4/oid4vp.js +++ b/lib/oid4/oid4vp.js @@ -3,7 +3,9 @@ */ import * as bedrock from '@bedrock/core'; import * as exchanges from '../exchanges.js'; -import {evaluateTemplate, unenvelopePresentation} from '../helpers.js'; +import { + evaluateTemplate, unenvelopePresentation, validateStep +} from '../helpers.js'; import { presentationSubmission as presentationSubmissionSchema, verifiablePresentation as verifiablePresentationSchema @@ -50,13 +52,8 @@ export async function getAuthorizationRequest({req}) { // `jsonata` per the JSON schema step = await evaluateTemplate( {workflow, exchange, typedTemplate: step.stepTemplate}); - if(Object.keys(step).length === 0) { - throw new BedrockError('Could not create authorization request.', { - name: 'DataError', - details: {httpStatusCode: 500, public: true} - }); - } } + await validateStep({step}); // step must have `openId` to perform OID4VP if(!step.openId) { diff --git a/lib/vcapi.js b/lib/vcapi.js index f35be1a..f7e2166 100644 --- a/lib/vcapi.js +++ b/lib/vcapi.js @@ -5,7 +5,7 @@ import * as bedrock from '@bedrock/core'; import * as exchanges from './exchanges.js'; import {createChallenge as _createChallenge, verify} from './verify.js'; import { - evaluateTemplate, generateRandom, unenvelopePresentation + evaluateTemplate, generateRandom, unenvelopePresentation, validateStep } from './helpers.js'; import {exportJWK, generateKeyPair, importJWK} from 'jose'; import {compile} from '@bedrock/validation'; @@ -121,13 +121,8 @@ export async function processExchange({req, res, workflow, exchangeRecord}) { // `jsonata` per the JSON schema step = await evaluateTemplate( {workflow, exchange, typedTemplate: step.stepTemplate}); - if(Object.keys(step).length === 0) { - throw new BedrockError('Empty step detected.', { - name: 'DataError', - details: {httpStatusCode: 500, public: true} - }); - } } + await validateStep({step}); // if next step is the same as the current step, throw an error if(step.nextStep === currentStep) { @@ -272,7 +267,7 @@ export async function processExchange({req, res, workflow, exchangeRecord}) { // issue any VCs; may return an empty response if the step defines no // VCs to issue - const {response} = await issue({workflow, exchange}); + const {response} = await issue({workflow, exchange, step}); // if last `step` has a redirect URL, include it in the response if(step?.redirectUrl) { diff --git a/schemas/bedrock-vc-workflow.js b/schemas/bedrock-vc-workflow.js index c536e95..c152c05 100644 --- a/schemas/bedrock-vc-workflow.js +++ b/schemas/bedrock-vc-workflow.js @@ -344,10 +344,12 @@ const step = { required: [ 'allowUnprotectedPresentation', 'createChallenge', - 'verifiablePresentationRequest', + 'issueRequests', 'jwtDidProofRequest', 'nextStep', - 'openId' + 'openId', + 'presentationSchema', + 'verifiablePresentationRequest' ] } }, { @@ -363,19 +365,24 @@ const step = { createChallenge: { type: 'boolean' }, - verifiablePresentationRequest: { - type: 'object' - }, - presentationSchema: { + issueRequests: { type: 'object', - required: ['type', 'jsonSchema'], + oneOf: [{ + required: ['credentialTemplateId'] + }, { + required: ['credentialTemplateIndex'] + }], additionalProperties: false, properties: { - type: { + credentialTemplateId: { type: 'string' }, - jsonSchema: { - type: 'object' + credentialTemplateIndex: { + type: 'number' + }, + // optionally specify different variables + variables: { + oneOf: [{type: 'string'}, {type: 'object'}] } } }, @@ -411,7 +418,6 @@ const step = { nextStep: { type: 'string' }, - stepTemplate: typedTemplate, // required to support OID4VP (but can be provided by step template instead) openId: { type: 'object', @@ -441,6 +447,23 @@ const step = { type: 'object' } } + }, + presentationSchema: { + type: 'object', + required: ['type', 'jsonSchema'], + additionalProperties: false, + properties: { + type: { + type: 'string' + }, + jsonSchema: { + type: 'object' + } + } + }, + stepTemplate: typedTemplate, + verifiablePresentationRequest: { + type: 'object' } } }; From e1810fffd00382cea33f65efb0f3d8f56afb9e82 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Oct 2024 20:43:16 -0400 Subject: [PATCH 2/5] Improve validation of `issueRequests`. --- schemas/bedrock-vc-workflow.js | 45 ++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/schemas/bedrock-vc-workflow.js b/schemas/bedrock-vc-workflow.js index c152c05..8d6ca36 100644 --- a/schemas/bedrock-vc-workflow.js +++ b/schemas/bedrock-vc-workflow.js @@ -330,6 +330,29 @@ export const issuerInstances = { items: issuerInstance }; +const issueRequestParameters = { + title: 'Issue Request Parameters', + type: 'object', + oneOf: [{ + required: ['credentialTemplateId'] + }, { + required: ['credentialTemplateIndex'] + }], + additionalProperties: false, + properties: { + credentialTemplateId: { + type: 'string' + }, + credentialTemplateIndex: { + type: 'number' + }, + // optionally specify different variables + variables: { + oneOf: [{type: 'string'}, {type: 'object'}] + } + } +}; + const step = { title: 'Exchange Step', type: 'object', @@ -366,25 +389,9 @@ const step = { type: 'boolean' }, issueRequests: { - type: 'object', - oneOf: [{ - required: ['credentialTemplateId'] - }, { - required: ['credentialTemplateIndex'] - }], - additionalProperties: false, - properties: { - credentialTemplateId: { - type: 'string' - }, - credentialTemplateIndex: { - type: 'number' - }, - // optionally specify different variables - variables: { - oneOf: [{type: 'string'}, {type: 'object'}] - } - } + type: 'array', + minItems: 0, + items: issueRequestParameters }, jwtDidProofRequest: { type: 'object', From 5ff12513428987bf30a5cfdb185513a7dfcadcd6 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Oct 2024 20:44:20 -0400 Subject: [PATCH 3/5] Fix `issueRequests` `vars` and make issue order consistent. --- lib/issue.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/issue.js b/lib/issue.js index dc20bdb..41b8bd0 100644 --- a/lib/issue.js +++ b/lib/issue.js @@ -92,7 +92,7 @@ async function _createIssueRequests({ variables: { // always include globals but allow local override globals: variables.globals, - ...r.variables + ...vars } }; })); @@ -125,10 +125,8 @@ async function _issue({workflow, issueRequests, format} = {}) { '/issue' : '/credentials/issue'; } - const issuedVCs = []; - // issue VCs in parallel - await Promise.all(issueRequests.map(async issueRequest => { + return Promise.all(issueRequests.map(async issueRequest => { /* Note: Issue request formats can be any one of these: 1. `{credential, options?}` @@ -141,8 +139,6 @@ async function _issue({workflow, issueRequests, format} = {}) { const { data: {verifiableCredential} } = await zcapClient.write({url, capability, json}); - issuedVCs.push(verifiableCredential); + return verifiableCredential; })); - - return issuedVCs; } From 7aaa0e2d9301c46cc5274ca1740089dc144baf97 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Oct 2024 20:44:54 -0400 Subject: [PATCH 4/5] Add tests with `issueRequests`. --- .../23-vcapi-verify-vc-issue-multi-vcs.js | 244 +++++++++ test/mocha/40-oid4vci-oid4vp-multi-vcs.js | 492 ++++++++++++++++++ 2 files changed, 736 insertions(+) create mode 100644 test/mocha/23-vcapi-verify-vc-issue-multi-vcs.js create mode 100644 test/mocha/40-oid4vci-oid4vp-multi-vcs.js diff --git a/test/mocha/23-vcapi-verify-vc-issue-multi-vcs.js b/test/mocha/23-vcapi-verify-vc-issue-multi-vcs.js new file mode 100644 index 0000000..4f213f5 --- /dev/null +++ b/test/mocha/23-vcapi-verify-vc-issue-multi-vcs.js @@ -0,0 +1,244 @@ +/*! + * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import {agent} from '@bedrock/https-agent'; +import {httpClient} from '@digitalbazaar/http-client'; +import {klona} from 'klona'; +import {mockData} from './mock.data.js'; +import {v4 as uuid} from 'uuid'; + +const { + baseUrl, didAuthnCredentialTemplate, strictDegreePresentationSchema +} = mockData; + +describe('exchange w/ VC-API delivery + "issueRequests"', () => { + let capabilityAgent; + + // provision a VC to use in the workflow below + let verifiableCredential; + let did; + let signer; + beforeEach(async () => { + const deps = await helpers.provisionDependencies(); + const { + workflowIssueZcap, + workflowCredentialStatusZcap, + workflowCreateChallengeZcap, + workflowVerifyPresentationZcap + } = deps; + ({capabilityAgent} = deps); + + // create workflow instance w/ oauth2-based authz + const zcaps = { + issue: workflowIssueZcap, + credentialStatus: workflowCredentialStatusZcap, + createChallenge: workflowCreateChallengeZcap, + verifyPresentation: workflowVerifyPresentationZcap + }; + const credentialTemplates = [{ + type: 'jsonata', + template: didAuthnCredentialTemplate + }]; + // require semantically-named workflow steps + const steps = { + // DID Authn step + didAuthn: { + createChallenge: true, + verifiablePresentationRequest: { + query: { + type: 'DIDAuthentication', + acceptedMethods: [{method: 'key'}] + }, + domain: baseUrl + } + } + }; + // set initial step + const initialStep = 'didAuthn'; + const workflowConfig = await helpers.createWorkflowConfig({ + capabilityAgent, zcaps, credentialTemplates, steps, initialStep, + oauth2: true + }); + const workflowId = workflowConfig.id; + const workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; + + // use workflow to provision verifiable credential + const credentialId = `urn:uuid:${uuid()}`; + const {exchangeId} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + credentialId, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap + }); + + // generate VP + ({did, signer} = await helpers.createDidProofSigner()); + const {verifiablePresentation} = await helpers.createDidAuthnVP({ + domain: baseUrl, + challenge: exchangeId.slice(exchangeId.lastIndexOf('/') + 1), + did, signer + }); + + // post VP to get VP w/VC in response + const response = await httpClient.post( + exchangeId, {agent, json: {verifiablePresentation}}); + const {verifiablePresentation: vp} = response.data; + verifiableCredential = vp.verifiableCredential[0]; + }); + + // provision workflow that will require the provisioned VC above + let workflowId; + let workflowRootZcap; + beforeEach(async () => { + const deps = await helpers.provisionDependencies(); + const { + workflowIssueZcap, + workflowCredentialStatusZcap, + workflowCreateChallengeZcap, + workflowVerifyPresentationZcap + } = deps; + ({capabilityAgent} = deps); + + // create workflow instance w/ oauth2-based authz + const zcaps = { + issue: workflowIssueZcap, + credentialStatus: workflowCredentialStatusZcap, + createChallenge: workflowCreateChallengeZcap, + verifyPresentation: workflowVerifyPresentationZcap + }; + const credentialTemplates = [{ + id: 'urn:credential-template-1', + type: 'jsonata', + template: didAuthnCredentialTemplate + }]; + const jsonSchema = klona(strictDegreePresentationSchema); + // FIXME: create a function to inject required `issuer` value + jsonSchema.properties.verifiableCredential.oneOf[0] + .properties.issuer = {const: verifiableCredential.issuer}; + jsonSchema.properties.verifiableCredential.oneOf[1].items + .properties.issuer = {const: verifiableCredential.issuer}; + // require semantically-named workflow steps + const steps = { + // DID Authn step, additionally require VC that was issued from + // workflow 1 + didAuthn: { + createChallenge: true, + verifiablePresentationRequest: { + query: [{ + type: 'DIDAuthentication', + acceptedMethods: [{method: 'key'}] + }, { + type: 'QueryByExample', + credentialQuery: [{ + reason: 'We require a verifiable credential to pass this test', + example: { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://www.w3.org/2018/credentials/examples/v1' + ], + type: 'UniversityDegreeCredential' + } + }] + }], + domain: baseUrl + }, + presentationSchema: { + type: 'JsonSchema', + jsonSchema + }, + // issue same VC twice + issueRequests: [{ + credentialTemplateId: 'urn:credential-template-1' + }, { + credentialTemplateId: 'urn:credential-template-1', + // use different variables + variables: { + credentialId: 'urn:different', + issuanceDate: '2024-01-01T00:00:00Z', + results: { + didAuthn: { + did: 'did:example:1' + } + } + } + }] + } + }; + // set initial step + const initialStep = 'didAuthn'; + const workflowConfig = await helpers.createWorkflowConfig({ + capabilityAgent, zcaps, credentialTemplates, steps, initialStep, + oauth2: true + }); + workflowId = workflowConfig.id; + workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; + }); + + it('should pass when sending VP in single call', async () => { + const credentialId = `urn:uuid:${uuid()}`; + const {exchangeId} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + credentialId, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap + }); + + // generate VP + const {verifiablePresentation} = await helpers.createDidAuthnVP({ + domain: baseUrl, + challenge: exchangeId.slice(exchangeId.lastIndexOf('/') + 1), + did, signer, verifiableCredential + }); + + // post VP to get VP in response + const response = await httpClient.post( + exchangeId, {agent, json: {verifiablePresentation}}); + should.exist(response?.data?.verifiablePresentation); + // ensure DID in VC matches `did` + const {verifiablePresentation: vp} = response.data; + should.exist(vp?.verifiableCredential?.[0]?.credentialSubject?.id); + should.exist(vp?.verifiableCredential?.[1]?.credentialSubject?.id); + const {verifiableCredential: [vc1, vc2]} = vp; + vc1.credentialSubject.id.should.equal(did); + // ensure VC ID matches + should.exist(vc1.id); + vc1.id.should.equal(credentialId); + + // check second VC + vc2.credentialSubject.id.should.equal('did:example:1'); + // ensure VC ID matches expected value + should.exist(vc2.id); + vc2.id.should.equal('urn:different'); + + // exchange should be complete and contain the VP and original VC + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: exchangeId, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + should.exist(exchange?.variables?.results?.didAuthn); + should.exist( + exchange?.variables?.results?.didAuthn?.verifiablePresentation); + exchange?.variables?.results?.didAuthn.did.should.equal(did); + exchange.variables.results.didAuthn.verifiablePresentation + .should.deep.equal(verifiablePresentation); + } catch(error) { + err = error; + } + should.not.exist(err); + } + }); +}); diff --git a/test/mocha/40-oid4vci-oid4vp-multi-vcs.js b/test/mocha/40-oid4vci-oid4vp-multi-vcs.js new file mode 100644 index 0000000..fbac067 --- /dev/null +++ b/test/mocha/40-oid4vci-oid4vp-multi-vcs.js @@ -0,0 +1,492 @@ +/*! + * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import { + OID4Client, oid4vp, parseCredentialOfferUrl +} from '@digitalbazaar/oid4-client'; +import {agent} from '@bedrock/https-agent'; +import {createPresentation} from '@digitalbazaar/vc'; +import {httpClient} from '@digitalbazaar/http-client'; +import {klona} from 'klona'; +import {mockData} from './mock.data.js'; +import { + unenvelopeCredential +} from '@bedrock/vc-delivery/lib/helpers.js'; +import {v4 as uuid} from 'uuid'; + +const { + baseUrl, nameCredentialTemplate, nameCredentialDefinition, + namePresentationSchema +} = mockData; +const credentialFormat = 'jwt_vc_json-ld'; + +const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1'; + +describe('exchange w/OID4VCI + "issueRequests"', () => { + let capabilityAgent; + + // provision a VC to use in the workflow below + let verifiableCredential; + let did; + let signer; + beforeEach(async () => { + const deps = await helpers.provisionDependencies({ + issuerOptions: { + issueOptions: { + // cryptosuites: [{ + // name: 'Ed25519Signature2020' + // }] + envelope: { + format: 'VC-JWT', + algorithm: 'Ed25519', + // works with or without options, but `EdDSA` will be chosen + // over `Ed25519` if `alg` not given an an Ed25519 key is used + /*options: { + alg: 'Ed25519' + }*/ + } + } + } + }); + const { + workflowIssueZcap, + workflowCredentialStatusZcap, + workflowCreateChallengeZcap, + workflowVerifyPresentationZcap + } = deps; + ({capabilityAgent} = deps); + + // create workflow instance w/ oauth2-based authz + const zcaps = { + issue: workflowIssueZcap, + credentialStatus: workflowCredentialStatusZcap, + createChallenge: workflowCreateChallengeZcap, + verifyPresentation: workflowVerifyPresentationZcap + }; + const credentialTemplates = [{ + type: 'jsonata', + template: nameCredentialTemplate + }]; + // require semantically-named workflow steps + const steps = { + // DID Authn step + didAuthn: { + createChallenge: true, + verifiablePresentationRequest: { + query: { + type: 'DIDAuthentication', + acceptedMethods: [{method: 'key'}] + }, + domain: baseUrl + } + } + }; + // set initial step + const initialStep = 'didAuthn'; + const workflowConfig = await helpers.createWorkflowConfig({ + capabilityAgent, zcaps, credentialTemplates, steps, initialStep, + oauth2: true + }); + const workflowId = workflowConfig.id; + const workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; + + // use workflow to provision verifiable credential + const credentialId = `urn:uuid:${uuid()}`; + const {exchangeId} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: nameCredentialDefinition, + credentialId, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap + }); + + // generate VP + ({did, signer} = await helpers.createDidProofSigner({didMethod: 'jwk'})); + const {verifiablePresentation} = await helpers.createDidAuthnVP({ + domain: baseUrl, + challenge: exchangeId.slice(exchangeId.lastIndexOf('/') + 1), + did, signer + }); + + // post VP to get VP w/VC in response + const response = await httpClient.post( + exchangeId, {agent, json: {verifiablePresentation}}); + const {verifiablePresentation: vp} = response.data; + verifiableCredential = vp.verifiableCredential[0]; + }); + + // provision workflow that will require the provisioned VC above + let workflowId; + let workflowRootZcap; + beforeEach(async () => { + const deps = await helpers.provisionDependencies({ + issuerOptions: { + issueOptions: { + // cryptosuites: [{ + // name: 'Ed25519Signature2020' + // }] + envelope: { + format: 'VC-JWT', + algorithm: 'Ed25519', + // works with or without options, but `EdDSA` will be chosen + // over `Ed25519` if `alg` not given an an Ed25519 key is used + /*options: { + alg: 'Ed25519' + }*/ + } + } + } + }); + const { + workflowIssueZcap, + workflowCredentialStatusZcap, + workflowCreateChallengeZcap, + workflowVerifyPresentationZcap + } = deps; + ({capabilityAgent} = deps); + + // create workflow instance w/ oauth2-based authz + const zcaps = { + issue: workflowIssueZcap, + credentialStatus: workflowCredentialStatusZcap, + createChallenge: workflowCreateChallengeZcap, + verifyPresentation: workflowVerifyPresentationZcap + }; + const credentialTemplates = [{ + type: 'jsonata', + template: nameCredentialTemplate + }]; + // require semantically-named workflow steps + const steps = { + // DID Authn step + didAuthn: { + stepTemplate: { + type: 'jsonata', + template: ` + { + "presentationSchema": presentationSchema, + "createChallenge": true, + "verifiablePresentationRequest": verifiablePresentationRequest, + "openId": { + "createAuthorizationRequest": "authorizationRequest", + "client_id_scheme": "redirect_uri", + "client_id": globals.workflow.id & + "/exchanges/" & + globals.exchange.id & + "/openid/client/authorization/response" + }, + "issueRequests": [{ + "credentialTemplateIndex": 0 + }, { + "credentialTemplateIndex": 0, + "variables": nested[1].secondVC + }] + }` + } + } + }; + // set initial step + const initialStep = 'didAuthn'; + const configOptions = { + credentialTemplates, steps, initialStep, + issuerInstances: [{ + supportedFormats: ['jwt_vc_json-ld'], + zcapReferenceIds: { + issue: 'issue' + } + }] + }; + const workflowConfig = await helpers.createWorkflowConfig({ + capabilityAgent, zcaps, configOptions, oauth2: true + }); + workflowId = workflowConfig.id; + workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; + }); + + // FIXME: add invalid issuer test that will fail against `presentationSchema` + + it('should pass w/ pre-authorized code flow', async () => { + // pre-authorized flow, issuer-initiated + const credentialId = `urn:uuid:${uuid()}`; + const vpr = { + query: [{ + type: 'DIDAuthentication', + acceptedMethods: [{method: 'key'}], + acceptedCryptosuites: [{cryptosuite: 'Ed25519Signature2020'}] + }, { + type: 'QueryByExample', + credentialQuery: [{ + reason: 'We require a name verifiable credential to pass this test', + example: { + '@context': 'https://www.w3.org/2018/credentials/v1', + type: 'VerifiableCredential', + credentialSubject: { + 'ex:name': '' + } + } + }] + }], + domain: baseUrl + }; + const jsonSchema = klona(namePresentationSchema); + // FIXME: create a function to inject required `issuer` value + jsonSchema.properties.verifiableCredential.oneOf[0] + .properties.issuer = {const: verifiableCredential.issuer}; + jsonSchema.properties.verifiableCredential.oneOf[1].items + .properties.issuer = {const: verifiableCredential.issuer}; + const { + exchangeId, + openIdUrl: issuanceUrl + } = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: nameCredentialDefinition, + credentialFormat, + credentialId, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap, + variables: { + credentialId, + verifiablePresentationRequest: vpr, + presentationSchema: { + type: 'JsonSchema', + jsonSchema + }, + openId: { + createAuthorizationRequest: 'authorizationRequest' + }, + // use convoluted path to test replacment variables can be found + nested: [{}, { + secondVC: { + credentialId: 'urn:different', + issuanceDate: '2024-01-01T00:00:00Z', + results: { + didAuthn: { + did: 'did:example:1' + } + } + } + }] + } + }); + const chapiRequest = {OID4VC: issuanceUrl}; + // CHAPI could potentially be used to deliver the URL to a native app + // that registered a "claimed URL" of `https://myapp.examples/ch` + // like so: + const claimedUrlFromChapi = 'https://myapp.example/ch?request=' + + encodeURIComponent(JSON.stringify(chapiRequest)); + const parsedClaimedUrl = new URL(claimedUrlFromChapi); + const parsedChapiRequest = JSON.parse( + parsedClaimedUrl.searchParams.get('request')); + const offer = parseCredentialOfferUrl({url: parsedChapiRequest.OID4VC}); + + // wallet / client gets access token + const client = await OID4Client.fromCredentialOffer({offer, agent}); + + // wallet / client attempts to receive credential, should receive a + // `presentation_required` error with an authorization request + let error; + try { + await client.requestCredential({ + credentialDefinition: nameCredentialDefinition, + did, + didProofSigner: signer, + agent, + format: credentialFormat + }); + } catch(e) { + error = e; + } + should.exist(error); + should.exist(error.cause); + error.cause.name.should.equal('NotAllowedError'); + should.exist(error.cause.cause); + error.cause.cause.data.error.should.equal('presentation_required'); + should.exist(error.cause.cause.data.authorization_request); + + // wallet / client responds to `authorization_request` by performing + // OID4VP: + let envelopedPresentation; + { + // generate VPR from authorization request + const { + cause: { + cause: {data: {authorization_request: authorizationRequest}} + } + } = error; + const {verifiablePresentationRequest} = await oid4vp.toVpr( + {authorizationRequest}); + + // VPR should be the same as the one from the exchange, modulo changes + // comply with OID4VP spec + const expectedVpr = { + query: [{ + type: 'DIDAuthentication', + // no OID4VP support for accepted DID methods at this time + acceptedCryptosuites: [ + {cryptosuite: 'ecdsa-rdfc-2019'}, + {cryptosuite: 'eddsa-rdfc-2022'}, + {cryptosuite: 'Ed25519Signature2020'} + ] + }, { + type: 'QueryByExample', + credentialQuery: [{ + reason: 'We require a name verifiable credential to pass this test', + example: { + '@context': 'https://www.w3.org/2018/credentials/v1', + type: 'VerifiableCredential', + credentialSubject: { + 'ex:name': '' + } + } + }] + }], + // OID4VP requires this to be the authz response URL + domain: authorizationRequest.response_uri, + // challenge should be set to authz nonce + challenge: authorizationRequest.nonce + }; + verifiablePresentationRequest.should.deep.equal(expectedVpr); + + // generate enveloped VP + const {domain, challenge} = verifiablePresentationRequest; + const presentation = createPresentation({holder: did}); + // force VC-JWT 1.1 mode with `verifiableCredential` as a string + presentation['@context'] = [VC_CONTEXT_1]; + const credentialJwt = verifiableCredential.id.slice( + 'data:application/jwt,'.length); + presentation.verifiableCredential = [credentialJwt]; + const envelopeResult = await helpers.envelopePresentation({ + verifiablePresentation: presentation, + challenge, + domain, + signer + }); + ({envelopedPresentation} = envelopeResult); + const {jwt} = envelopeResult; + + // send authorization response + // FIXME: auto-generate proper presentation submission + const presentationSubmission = { + id: 'ex:example', + definition_id: 'ex:definition', + descriptor_map: [] + }; + const { + result/*, presentationSubmission*/ + } = await oid4vp.sendAuthorizationResponse({ + verifiablePresentation: presentation, authorizationRequest, + vpToken: JSON.stringify(jwt), agent, + presentationSubmission + }); + should.exist(result); + + // exchange should be `active` and contain the VP and open ID results + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: exchangeId, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('active'); + should.exist(exchange?.variables?.results?.didAuthn); + should.exist( + exchange?.variables?.results?.didAuthn?.verifiablePresentation); + exchange?.variables?.results?.didAuthn.did.should.equal(did); + exchange.variables.results.didAuthn.envelopedPresentation + .should.deep.equal(envelopedPresentation); + exchange.variables.results.didAuthn.verifiablePresentation.holder + .should.equal(did); + should.exist(exchange.variables.results.didAuthn.openId); + exchange.variables.results.didAuthn.openId.authorizationRequest + .should.deep.equal(authorizationRequest); + exchange.variables.results.didAuthn.openId.presentationSubmission + .should.deep.equal(presentationSubmission); + } catch(error) { + err = error; + } + should.not.exist(err, err?.message); + } + } + + // wallet / client attempts to receive credential now that OID4VP is done + let result; + error = undefined; + try { + result = await client.requestCredential({ + credentialDefinition: nameCredentialDefinition, + did, + didProofSigner: signer, + agent, + format: credentialFormat + }); + } catch(e) { + error = e; + } + should.not.exist(error); + should.exist(result); + result.should.include.keys(['format', 'credentials']); + result.format.should.equal(credentialFormat); + result.credentials.should.be.an('array'); + + // check first VC + { + result.credentials[0].should.be.a('string'); + const {credential} = await unenvelopeCredential({ + envelopedCredential: result.credentials[0], + format: credentialFormat + }); + // ensure credential subject ID matches generated DID + should.exist(credential?.credentialSubject?.id); + credential.credentialSubject.id.should.equal(did); + // ensure VC ID matches + should.exist(credential.id); + credential.id.should.equal(credentialId); + } + + // check second VC + { + result.credentials[1].should.be.a('string'); + const {credential} = await unenvelopeCredential({ + envelopedCredential: result.credentials[1], + format: credentialFormat + }); + // ensure credential subject ID matches DID from secondVC vars + should.exist(credential?.credentialSubject?.id); + credential.credentialSubject.id.should.equal('did:example:1'); + // ensure VC ID matches + should.exist(credential.id); + credential.id.should.equal('urn:different'); + } + + // exchange should be complete and contain the VP and original VC + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: exchangeId, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + should.exist(exchange?.variables?.results?.didAuthn); + should.exist( + exchange?.variables?.results?.didAuthn?.verifiablePresentation); + exchange?.variables?.results?.didAuthn.did.should.equal(did); + exchange.variables.results.didAuthn.verifiablePresentation.holder + .should.deep.equal(did); + exchange.variables.results.didAuthn.envelopedPresentation + .should.deep.equal(envelopedPresentation); + } catch(error) { + err = error; + } + should.not.exist(err, err?.message); + } + }); +}); From 201c807a17d747e8c269d1299a11d60f3b3b23ad Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Oct 2024 20:57:10 -0400 Subject: [PATCH 5/5] Handle undefined step case. --- lib/issue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/issue.js b/lib/issue.js index 41b8bd0..eac61cb 100644 --- a/lib/issue.js +++ b/lib/issue.js @@ -47,7 +47,7 @@ async function _createIssueRequests({ // if step does not define `issueRequests`, then use all for templates for // backwards compatibility let params; - if(!step.issueRequests) { + if(!step?.issueRequests) { params = credentialTemplates.map(typedTemplate => ({typedTemplate})); } else { // resolve all issue requests params in parallel