diff --git a/CHANGELOG.md b/CHANGELOG.md index 878531f..e7409b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # bedrock-vc-delivery ChangeLog +## 5.4.0 - 2024-09-dd + +### Added +- Allow multiple credentials (if they are of the same type) to be returned + from a single OID4VCI exchange using the `credential` endpoint (not the + batch endpoint). + ## 5.3.5 - 2024-08-27 ### Fixed diff --git a/lib/oid4/http.js b/lib/oid4/http.js index 6abe791..00875b6 100644 --- a/lib/oid4/http.js +++ b/lib/oid4/http.js @@ -203,28 +203,33 @@ export async function createRoutes({ return; } - /* Note: The `/credential` route only supports sending a single VC; - assume here that this workflow is configured for a single VC and an - error code would have been sent to the client to use the batch - endpoint if there was more than one VC to deliver. */ - const {response, format} = result; - const {verifiablePresentation: {verifiableCredential: [vc]}} = response; + // send VC(s) + const { + response: {verifiablePresentation: {verifiableCredential}}, + format + } = result; + // FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)... + const credentials = verifiableCredential.map(vc => { + // parse any enveloped VC + let credential; + if(vc.type === 'EnvelopedVerifiableCredential' && + vc.id?.startsWith('data:application/jwt,')) { + credential = vc.id.slice('data:application/jwt,'.length); + } else { + credential = vc; + } + return credential; + }); - // parse any enveloped VC - let credential; - if(vc.type === 'EnvelopedVerifiableCredential' && - vc.id?.startsWith('data:application/jwt,')) { - credential = vc.id.slice('data:application/jwt,'.length); - } else { - credential = vc; - } + /* Note: The `/credential` route only supports sending VCs of the same + type, but there can be more than one of them. The above `isBatchRequest` + check will ensure that the workflow used here only allows a single + credential request, indicating a single type. */ // send OID4VCI response - res.json({ - // FIXME: this doesn't seem to be in the spec anymore (draft 14+)... - format, - credential - }); + const response = credentials.length === 1 ? + {format, credential: credentials[0]} : {format, credentials}; + res.json(response); })); // a credential delivery server endpoint diff --git a/test/mocha/20-vcapi.js b/test/mocha/20-vcapi.js index ff1cfe0..b376b06 100644 --- a/test/mocha/20-vcapi.js +++ b/test/mocha/20-vcapi.js @@ -278,7 +278,7 @@ describe('exchange w/ VC-API delivery', () => { // wait for exchange to expire now = new Date(); await new Promise( - r => setTimeout(r, expires.getTime() - now.getTime())); + r => setTimeout(r, expires.getTime() - now.getTime() + 1)); } catch(error) { err = error; } diff --git a/test/mocha/38-oid4vci-multi.js b/test/mocha/38-oid4vci-multi.js new file mode 100644 index 0000000..bf8f59e --- /dev/null +++ b/test/mocha/38-oid4vci-multi.js @@ -0,0 +1,362 @@ +/*! + * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import { + getCredentialOffer, + OID4Client, + parseCredentialOfferUrl +} from '@digitalbazaar/oid4-client'; +import {agent} from '@bedrock/https-agent'; +import {mockData} from './mock.data.js'; +import {v4 as uuid} from 'uuid'; + +const {credentialTemplate} = mockData; + +describe('exchange multiple VCs w/OID4VCI delivery', () => { + let capabilityAgent; + 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 = [{ + type: 'jsonata', + template: credentialTemplate.replace('credentialId', 'credentialId1') + }, { + type: 'jsonata', + template: credentialTemplate.replace('credentialId', 'credentialId2') + }]; + const workflowConfig = await helpers.createWorkflowConfig( + {capabilityAgent, zcaps, credentialTemplates, oauth2: true}); + workflowId = workflowConfig.id; + workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; + }); + + it('should pass w/ pre-authorized code flow', async () => { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html + + /* This flow demonstrates passing an OID4VCI issuance initiation URL + through a CHAPI OID4VCI request. The request is passed to a "Claimed URL" + which was registered on a user's device by a native app. The native app's + domain also published a "manifest.json" file that expressed the same + "Claimed URL" via `credential_handler.url='https://myapp.example/ch'` and + `credential_handler.launchType='redirect'` (TBD). */ + + // pre-authorized flow, issuer-initiated + const credentialId1 = `urn:uuid:${uuid()}`; + const credentialId2 = `urn:uuid:${uuid()}`; + const {openIdUrl: offerUrl} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + variables: { + credentialId1, + credentialId2 + }, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap + }); + const chapiRequest = {OID4VC: offerUrl}; + // 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 receives credential + const result = await client.requestCredential({agent}); + should.exist(result); + result.should.include.keys(['format', 'credentials']); + result.format.should.equal('ldp_vc'); + + const credentialIdsFound = new Set(); + for(const credential of result.credentials) { + // ensure each credential subject ID matches static DID + should.exist(credential.credentialSubject?.id); + credential.credentialSubject.id.should.equal( + 'did:example:ebfeb1f712ebc6f1c276e12ec21'); + // gather VC IDs to check below + should.exist(credential.id); + credentialIdsFound.add(credential.id); + } + // ensure each VC ID matches + credentialIdsFound.size.should.equal(2); + credentialIdsFound.has(credentialId1).should.equal(true); + credentialIdsFound.has(credentialId2).should.equal(true); + + // exchange state should be complete + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: offer.credential_issuer, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + } catch(error) { + err = error; + } + should.not.exist(err); + } + }); + + it('should pass w/ credentials as ID strings in offer', async () => { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html + + /* This flow demonstrates passing an OID4VCI issuance initiation URL + through a CHAPI OID4VCI request. The request is passed to a "Claimed URL" + which was registered on a user's device by a native app. The native app's + domain also published a "manifest.json" file that expressed the same + "Claimed URL" via `credential_handler.url='https://myapp.example/ch'` and + `credential_handler.launchType='redirect'` (TBD). */ + + // pre-authorized flow, issuer-initiated + const credentialId1 = `urn:uuid:${uuid()}`; + const credentialId2 = `urn:uuid:${uuid()}`; + const {openIdUrl: offerUrl} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + variables: { + credentialId1, + credentialId2 + }, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap, + useCredentialIds: true + }); + const chapiRequest = {OID4VC: offerUrl}; + // 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 receives credential + const result = await client.requestCredential({agent}); + should.exist(result); + result.should.include.keys(['format', 'credentials']); + result.format.should.equal('ldp_vc'); + + const credentialIdsFound = new Set(); + for(const credential of result.credentials) { + // ensure each credential subject ID matches static DID + should.exist(credential.credentialSubject?.id); + credential.credentialSubject.id.should.equal( + 'did:example:ebfeb1f712ebc6f1c276e12ec21'); + // gather VC IDs to check below + should.exist(credential.id); + credentialIdsFound.add(credential.id); + } + // ensure each VC ID matches + credentialIdsFound.size.should.equal(2); + credentialIdsFound.has(credentialId1).should.equal(true); + credentialIdsFound.has(credentialId2).should.equal(true); + + // exchange state should be complete + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: offer.credential_issuer, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + } catch(error) { + err = error; + } + should.not.exist(err); + } + }); + + it('should pass w/ credential configuration IDs', async () => { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html + + /* This flow demonstrates passing an OID4VCI issuance initiation URL + through a CHAPI OID4VCI request. The request is passed to a "Claimed URL" + which was registered on a user's device by a native app. The native app's + domain also published a "manifest.json" file that expressed the same + "Claimed URL" via `credential_handler.url='https://myapp.example/ch'` and + `credential_handler.launchType='redirect'` (TBD). */ + + // pre-authorized flow, issuer-initiated + const credentialId1 = `urn:uuid:${uuid()}`; + const credentialId2 = `urn:uuid:${uuid()}`; + const {openIdUrl: offerUrl} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + variables: { + credentialId1, + credentialId2 + }, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap, + useCredentialConfigurationIds: true + }); + const chapiRequest = {OID4VC: offerUrl}; + // 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 receives credential + const result = await client.requestCredential({agent}); + should.exist(result); + result.should.include.keys(['format', 'credentials']); + result.format.should.equal('ldp_vc'); + + const credentialIdsFound = new Set(); + for(const credential of result.credentials) { + // ensure each credential subject ID matches static DID + should.exist(credential.credentialSubject?.id); + credential.credentialSubject.id.should.equal( + 'did:example:ebfeb1f712ebc6f1c276e12ec21'); + // gather VC IDs to check below + should.exist(credential.id); + credentialIdsFound.add(credential.id); + } + // ensure each VC ID matches + credentialIdsFound.size.should.equal(2); + credentialIdsFound.has(credentialId1).should.equal(true); + credentialIdsFound.has(credentialId2).should.equal(true); + + // exchange state should be complete + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: offer.credential_issuer, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + } catch(error) { + err = error; + } + should.not.exist(err); + } + }); + + it('should pass w/ "credential_offer_uri"', async () => { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html + + /* This flow demonstrates passing an OID4VCI issuance initiation URL + through a CHAPI OID4VCI request. The request is passed to a "Claimed URL" + which was registered on a user's device by a native app. The native app's + domain also published a "manifest.json" file that expressed the same + "Claimed URL" via `credential_handler.url='https://myapp.example/ch'` and + `credential_handler.launchType='redirect'` (TBD). */ + + // pre-authorized flow, issuer-initiated + const credentialId1 = `urn:uuid:${uuid()}`; + const credentialId2 = `urn:uuid:${uuid()}`; + const {openIdUrl: offerUrl} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + variables: { + credentialId1, + credentialId2 + }, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap, + useCredentialOfferUri: true + }); + const chapiRequest = {OID4VC: offerUrl}; + // 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 = await getCredentialOffer({ + url: parsedChapiRequest.OID4VC, agent + }); + + // wallet / client gets access token + const client = await OID4Client.fromCredentialOffer({offer, agent}); + + // wallet / client receives credential + const result = await client.requestCredential({agent}); + should.exist(result); + result.should.include.keys(['format', 'credentials']); + result.format.should.equal('ldp_vc'); + + const credentialIdsFound = new Set(); + for(const credential of result.credentials) { + // ensure each credential subject ID matches static DID + should.exist(credential.credentialSubject?.id); + credential.credentialSubject.id.should.equal( + 'did:example:ebfeb1f712ebc6f1c276e12ec21'); + // gather VC IDs to check below + should.exist(credential.id); + credentialIdsFound.add(credential.id); + } + // ensure each VC ID matches + credentialIdsFound.size.should.equal(2); + credentialIdsFound.has(credentialId1).should.equal(true); + credentialIdsFound.has(credentialId2).should.equal(true); + + // exchange state should be complete + { + let err; + try { + const {exchange} = await helpers.getExchange( + {id: offer.credential_issuer, capabilityAgent}); + should.exist(exchange?.state); + exchange.state.should.equal('complete'); + } catch(error) { + err = error; + } + should.not.exist(err); + } + }); +});