@bedrock/vc-delivery 7.13.2 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ExchangeProcessor.js +72 -46
- package/lib/helpers.js +39 -0
- package/lib/issue.js +54 -18
- package/lib/oid4/authorizationResponse.js +0 -1
- package/lib/oid4/http.js +13 -3
- package/lib/oid4/oid4vci.js +478 -273
- package/lib/oid4/oid4vciDraft13.js +197 -0
- package/lib/oid4/oid4vp.js +6 -1
- package/lib/vcapi.js +27 -2
- package/package.json +2 -2
- package/schemas/bedrock-vc-workflow.js +220 -180
package/lib/ExchangeProcessor.js
CHANGED
|
@@ -49,6 +49,43 @@ export class ExchangeProcessor {
|
|
|
49
49
|
this.isStepComplete = isStepComplete?.bind(this);
|
|
50
50
|
this.issue = issue ?? defaultIssue.bind(this);
|
|
51
51
|
this.verify = verify ?? defaultVerify.bind(this);
|
|
52
|
+
this.canRetry = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A utility function for getting the current step of the exchange without
|
|
57
|
+
* performing any other processing.
|
|
58
|
+
*
|
|
59
|
+
* @returns {Promise<object>} An object with the step information.
|
|
60
|
+
*/
|
|
61
|
+
async getStep() {
|
|
62
|
+
const {
|
|
63
|
+
workflow,
|
|
64
|
+
exchangeRecord: {exchange}
|
|
65
|
+
} = this;
|
|
66
|
+
|
|
67
|
+
const currentStep = exchange.step;
|
|
68
|
+
if(!currentStep) {
|
|
69
|
+
// create default empty step
|
|
70
|
+
exchange.step = 'initial';
|
|
71
|
+
workflow.steps = {initial: {}};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const step = await evaluateExchangeStep({
|
|
75
|
+
workflow, exchange, stepName: currentStep
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// if `step.nextStep` and `step.redirectUrl` and are both set, throw an
|
|
79
|
+
// error
|
|
80
|
+
if(step.nextStep && step.redirectUrl) {
|
|
81
|
+
throw new BedrockError(
|
|
82
|
+
'Only the last step of a workflow can use "redirectUrl".', {
|
|
83
|
+
name: 'DataError',
|
|
84
|
+
details: {httpStatusCode: 500, public: true}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return step;
|
|
52
89
|
}
|
|
53
90
|
|
|
54
91
|
/**
|
|
@@ -113,6 +150,34 @@ export class ExchangeProcessor {
|
|
|
113
150
|
}
|
|
114
151
|
}
|
|
115
152
|
|
|
153
|
+
/**
|
|
154
|
+
* A utility function for updating the exchange. This should only be called
|
|
155
|
+
* to help address differences in protocols or handle backwards compatibility
|
|
156
|
+
* behavior.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} options - The options to use.
|
|
159
|
+
* @param {object} [options.step] - The current step.
|
|
160
|
+
*
|
|
161
|
+
* @returns {Promise<object>} An object with processing information.
|
|
162
|
+
*/
|
|
163
|
+
async updateExchange({step} = {}) {
|
|
164
|
+
const {workflow, exchangeRecord: {exchange, meta}} = this;
|
|
165
|
+
try {
|
|
166
|
+
exchange.referenceId = globalThis.crypto.randomUUID();
|
|
167
|
+
exchange.sequence++;
|
|
168
|
+
if(exchange.state === 'complete') {
|
|
169
|
+
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
170
|
+
} else {
|
|
171
|
+
await exchanges.update({workflowId: workflow.id, exchange});
|
|
172
|
+
}
|
|
173
|
+
meta.updated = Date.now();
|
|
174
|
+
await emitExchangeUpdated({workflow, exchange, step});
|
|
175
|
+
} catch(e) {
|
|
176
|
+
exchange.sequence--;
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
116
181
|
async _validateReceivedPresentation({
|
|
117
182
|
workflow, exchange, step, receivedPresentation, verify
|
|
118
183
|
}) {
|
|
@@ -287,7 +352,7 @@ export class ExchangeProcessor {
|
|
|
287
352
|
|
|
288
353
|
// 4.1. Set `step` to the current step (evaluating a step template as
|
|
289
354
|
// needed).
|
|
290
|
-
step = await
|
|
355
|
+
step = await this.getStep();
|
|
291
356
|
|
|
292
357
|
// 4.2. Call subalgorithm `prepareStep`, passing `workflow`,
|
|
293
358
|
// `exchange`, `step`, `receivedPresentation`, and
|
|
@@ -358,7 +423,7 @@ export class ExchangeProcessor {
|
|
|
358
423
|
|
|
359
424
|
// 4.7.3. Save the exchange (and call any non-blocking callback
|
|
360
425
|
// in the step) and return `response`.
|
|
361
|
-
await
|
|
426
|
+
await this.updateExchange({step});
|
|
362
427
|
return response;
|
|
363
428
|
}
|
|
364
429
|
|
|
@@ -383,7 +448,7 @@ export class ExchangeProcessor {
|
|
|
383
448
|
}
|
|
384
449
|
// 4.9.1.2. Save the exchange (and call any non-blocking callback
|
|
385
450
|
// in the step).
|
|
386
|
-
await
|
|
451
|
+
await this.updateExchange({step});
|
|
387
452
|
// 4.9.1.3. Return `response`.
|
|
388
453
|
return response;
|
|
389
454
|
}
|
|
@@ -464,7 +529,7 @@ export class ExchangeProcessor {
|
|
|
464
529
|
|
|
465
530
|
// 4.14. Save the exchange (and call any non-blocking callback in
|
|
466
531
|
// the step).
|
|
467
|
-
await
|
|
532
|
+
await this.updateExchange({step});
|
|
468
533
|
|
|
469
534
|
// 4.15. If `exchange.state` is `complete`, return `response` if it is
|
|
470
535
|
// not `null`, otherwise return an empty object.
|
|
@@ -477,7 +542,9 @@ export class ExchangeProcessor {
|
|
|
477
542
|
}
|
|
478
543
|
} catch(e) {
|
|
479
544
|
if(e.name === 'InvalidStateError') {
|
|
480
|
-
|
|
545
|
+
// if issuance has not been triggered or the exchange processor's
|
|
546
|
+
// `canRetry` flag has been explicitly set, allow retry
|
|
547
|
+
retryState.canRetry = !issuanceTriggered || this.canRetry;
|
|
481
548
|
throw e;
|
|
482
549
|
}
|
|
483
550
|
// write last error if exchange hasn't been frequently updated
|
|
@@ -495,30 +562,6 @@ export class ExchangeProcessor {
|
|
|
495
562
|
}
|
|
496
563
|
}
|
|
497
564
|
|
|
498
|
-
async function _getStep({workflow, exchange}) {
|
|
499
|
-
const currentStep = exchange.step;
|
|
500
|
-
if(!currentStep) {
|
|
501
|
-
// return default empty step and set dummy stepname for exchange
|
|
502
|
-
exchange.step = 'initial';
|
|
503
|
-
return {};
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const step = await evaluateExchangeStep({
|
|
507
|
-
workflow, exchange, stepName: currentStep
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// if `step.nextStep` and `step.redirectUrl` and are both set, throw an error
|
|
511
|
-
if(step.nextStep && step.redirectUrl) {
|
|
512
|
-
throw new BedrockError(
|
|
513
|
-
'Only the last step of a workflow can use "redirectUrl".', {
|
|
514
|
-
name: 'DataError',
|
|
515
|
-
details: {httpStatusCode: 500, public: true}
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
return step;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
565
|
function _createTimeoutSignal({exchange, meta}) {
|
|
523
566
|
const expires = exchange.expires !== undefined ?
|
|
524
567
|
new Date(exchange.expires).getTime() :
|
|
@@ -569,20 +612,3 @@ async function _createVerifiablePresentationRequest({
|
|
|
569
612
|
function _isInitialStep({workflow, exchange}) {
|
|
570
613
|
return !workflow.initialStep || exchange.step === workflow.initialStep;
|
|
571
614
|
}
|
|
572
|
-
|
|
573
|
-
async function _updateExchange({workflow, exchange, meta, step}) {
|
|
574
|
-
try {
|
|
575
|
-
exchange.referenceId = globalThis.crypto.randomUUID();
|
|
576
|
-
exchange.sequence++;
|
|
577
|
-
if(exchange.state === 'complete') {
|
|
578
|
-
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
579
|
-
} else {
|
|
580
|
-
await exchanges.update({workflowId: workflow.id, exchange});
|
|
581
|
-
}
|
|
582
|
-
meta.updated = Date.now();
|
|
583
|
-
await emitExchangeUpdated({workflow, exchange, step});
|
|
584
|
-
} catch(e) {
|
|
585
|
-
exchange.sequence--;
|
|
586
|
-
throw e;
|
|
587
|
-
}
|
|
588
|
-
}
|
package/lib/helpers.js
CHANGED
|
@@ -21,6 +21,18 @@ const ALLOWED_ERROR_KEYS = [
|
|
|
21
21
|
'status'
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
+
// media type and format support constants
|
|
25
|
+
export const SUPPORTED_FORMAT_TO_MEDIA_TYPE = new Map([
|
|
26
|
+
['application/vc', 'application/vc'],
|
|
27
|
+
['ldp_vc', 'application/vc'],
|
|
28
|
+
['jwt_vc_json', 'application/jwt']
|
|
29
|
+
]);
|
|
30
|
+
// create reverse map
|
|
31
|
+
export const SUPPORTED_MEDIA_TYPE_TO_FORMAT = new Map([
|
|
32
|
+
['application/vc', 'ldp_vc'],
|
|
33
|
+
['application/jwt', 'jwt_vc_json']
|
|
34
|
+
]);
|
|
35
|
+
|
|
24
36
|
export function buildPresentationFromResults({
|
|
25
37
|
presentation, verifyResult
|
|
26
38
|
}) {
|
|
@@ -120,6 +132,10 @@ export async function evaluateExchangeStep({
|
|
|
120
132
|
step = await evaluateTemplate({
|
|
121
133
|
workflow, exchange, typedTemplate: step.stepTemplate
|
|
122
134
|
});
|
|
135
|
+
} else {
|
|
136
|
+
// ensure step is cloned to allow for changes during exchange processing
|
|
137
|
+
// that will not persist
|
|
138
|
+
step = structuredClone(step);
|
|
123
139
|
}
|
|
124
140
|
await validateStep({step});
|
|
125
141
|
return step;
|
|
@@ -164,12 +180,35 @@ export function getWorkflowIssuerInstances({workflow} = {}) {
|
|
|
164
180
|
if(!issuerInstances && workflow.zcaps.issue) {
|
|
165
181
|
// generate dynamic issuer instance config
|
|
166
182
|
issuerInstances = [{
|
|
183
|
+
// provide both media types and (deprecated) formats for backwards compat
|
|
184
|
+
supportedMediaTypes: ['application/vc'],
|
|
167
185
|
supportedFormats: ['application/vc', 'ldp_vc'],
|
|
168
186
|
zcapReferenceIds: {
|
|
169
187
|
issue: 'issue'
|
|
170
188
|
}
|
|
171
189
|
}];
|
|
172
190
|
}
|
|
191
|
+
// normalize `supportedFormats` to `supportedMediaTypes` and add
|
|
192
|
+
// `supportedFormats` for backwards compatibility
|
|
193
|
+
issuerInstances = issuerInstances.map(ii => {
|
|
194
|
+
if(ii.supportedMediaTypes && ii.supportedFormats) {
|
|
195
|
+
return ii;
|
|
196
|
+
}
|
|
197
|
+
if(ii.supportedFormats) {
|
|
198
|
+
return {
|
|
199
|
+
...ii,
|
|
200
|
+
supportedMediaTypes: ii.supportedFormats.map(
|
|
201
|
+
format => SUPPORTED_FORMAT_TO_MEDIA_TYPE.get(format) ?? format)
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if(ii.supportedMediaTypes) {
|
|
205
|
+
return {
|
|
206
|
+
...ii,
|
|
207
|
+
supportedFormats: ii.supportedMediaTypes.map(
|
|
208
|
+
mt => SUPPORTED_MEDIA_TYPE_TO_FORMAT.get(mt) ?? mt)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
});
|
|
173
212
|
return issuerInstances;
|
|
174
213
|
}
|
|
175
214
|
|
package/lib/issue.js
CHANGED
|
@@ -7,14 +7,16 @@ import {
|
|
|
7
7
|
getTemplateVariables,
|
|
8
8
|
getWorkflowIssuerInstances,
|
|
9
9
|
getZcapClient,
|
|
10
|
-
setVariable
|
|
10
|
+
setVariable,
|
|
11
|
+
SUPPORTED_FORMAT_TO_MEDIA_TYPE,
|
|
11
12
|
} from './helpers.js';
|
|
12
13
|
import {createPresentation} from '@digitalbazaar/vc';
|
|
13
14
|
|
|
14
15
|
const {util: {BedrockError}} = bedrock;
|
|
15
16
|
|
|
16
17
|
export async function issue({
|
|
17
|
-
workflow, exchange, step,
|
|
18
|
+
workflow, exchange, step, mediaType = 'application/vc',
|
|
19
|
+
format,
|
|
18
20
|
issueRequestsParams,
|
|
19
21
|
verifiablePresentation,
|
|
20
22
|
// FIXME: remove `filter`
|
|
@@ -32,11 +34,16 @@ export async function issue({
|
|
|
32
34
|
return {response: {}, exchangeChanged: false};
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
if(format) {
|
|
38
|
+
// fall back to using `format` as media type if necessary
|
|
39
|
+
mediaType = SUPPORTED_FORMAT_TO_MEDIA_TYPE.get(format) ?? format;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// run all issue requests
|
|
36
43
|
const {
|
|
37
44
|
credentials: issuedVcs,
|
|
38
45
|
exchangeChanged
|
|
39
|
-
} = await _issue({workflow, exchange, issueRequests,
|
|
46
|
+
} = await _issue({workflow, exchange, issueRequests, mediaType});
|
|
40
47
|
|
|
41
48
|
if(issuedVcs.length === 0 && !step?.verifiablePresentation) {
|
|
42
49
|
// no issued VCs/no VP to return in response
|
|
@@ -122,6 +129,7 @@ export function getIssueRequestsParams({workflow, exchange, step}) {
|
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
const params = {
|
|
132
|
+
oid4vci: r.oid4vci,
|
|
125
133
|
typedTemplate,
|
|
126
134
|
variables: {
|
|
127
135
|
// always include globals but allow local override
|
|
@@ -140,6 +148,7 @@ async function _evalIssueRequests({
|
|
|
140
148
|
workflow, exchange, step, issueRequestsParams, filter
|
|
141
149
|
}) {
|
|
142
150
|
// evaluate all issue requests in parallel
|
|
151
|
+
// FIXME: require `issueRequestsParams` and remove `filter` param
|
|
143
152
|
const results = issueRequestsParams ??
|
|
144
153
|
getIssueRequestsParams({workflow, exchange, step}).filter(filter);
|
|
145
154
|
return Promise.all(results.map(async params => {
|
|
@@ -153,33 +162,54 @@ async function _evalIssueRequests({
|
|
|
153
162
|
}));
|
|
154
163
|
}
|
|
155
164
|
|
|
156
|
-
function _getIssueZcap({
|
|
157
|
-
const
|
|
165
|
+
function _getIssueZcap({issuerInstances, zcaps, issueRequest, mediaType}) {
|
|
166
|
+
const {
|
|
167
|
+
issuerInstanceId,
|
|
168
|
+
oid4vci: {credentialConfigurationId} = {}
|
|
169
|
+
} = issueRequest;
|
|
158
170
|
const {zcapReferenceIds: {issue: issueRefId}} = issuerInstances.find(
|
|
159
|
-
({
|
|
171
|
+
({id, oid4vci, supportedMediaTypes}) => {
|
|
172
|
+
// find by `issuerInstanceId` if given
|
|
173
|
+
if(id && id === issuerInstanceId) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// find by `credentialConfigurationId` if given
|
|
177
|
+
if(credentialConfigurationId && oid4vci) {
|
|
178
|
+
return oid4vci.supportedConfigurationIds.includes(
|
|
179
|
+
credentialConfigurationId);
|
|
180
|
+
}
|
|
181
|
+
// find by `mediaType` instead
|
|
182
|
+
return supportedMediaTypes.includes(mediaType);
|
|
183
|
+
});
|
|
160
184
|
return zcaps[issueRefId];
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
async function _issue({workflow, exchange, issueRequests,
|
|
187
|
+
async function _issue({workflow, exchange, issueRequests, mediaType} = {}) {
|
|
164
188
|
// create zcap client for issuing VCs
|
|
165
189
|
const {zcapClient, zcaps} = await getZcapClient({workflow});
|
|
166
190
|
|
|
167
|
-
// get
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
// specify URL to `/credentials/issue` to handle case that capability
|
|
171
|
-
// is not specific to it
|
|
172
|
-
let url = capability.invocationTarget;
|
|
173
|
-
if(!capability.invocationTarget.endsWith('/credentials/issue')) {
|
|
174
|
-
url += capability.invocationTarget.endsWith('/credentials') ?
|
|
175
|
-
'/issue' : '/credentials/issue';
|
|
176
|
-
}
|
|
191
|
+
// get issuer instances
|
|
192
|
+
const issuerInstances = getWorkflowIssuerInstances({workflow});
|
|
177
193
|
|
|
178
194
|
// issue VCs in parallel
|
|
179
195
|
let exchangeChanged = false;
|
|
196
|
+
const storedCredentials = [];
|
|
180
197
|
const results = await Promise.all(issueRequests.map(async issueRequest => {
|
|
181
198
|
const {params, body} = issueRequest;
|
|
182
199
|
|
|
200
|
+
// get the zcap to use for the issue request
|
|
201
|
+
const capability = _getIssueZcap({
|
|
202
|
+
issuerInstances, zcaps, issueRequest, mediaType
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// specify URL to `/credentials/issue` to handle case that capability
|
|
206
|
+
// is not specific to it
|
|
207
|
+
let url = capability.invocationTarget;
|
|
208
|
+
if(!capability.invocationTarget.endsWith('/credentials/issue')) {
|
|
209
|
+
url += capability.invocationTarget.endsWith('/credentials') ?
|
|
210
|
+
'/issue' : '/credentials/issue';
|
|
211
|
+
}
|
|
212
|
+
|
|
183
213
|
/* Note: Issue request body can be any one of these:
|
|
184
214
|
|
|
185
215
|
1. `{credential, options?}`
|
|
@@ -200,6 +230,10 @@ async function _issue({workflow, exchange, issueRequests, format} = {}) {
|
|
|
200
230
|
name: params.result,
|
|
201
231
|
value: verifiableCredential
|
|
202
232
|
});
|
|
233
|
+
storedCredentials.push([{
|
|
234
|
+
name: params.result,
|
|
235
|
+
value: verifiableCredential
|
|
236
|
+
}]);
|
|
203
237
|
return;
|
|
204
238
|
}
|
|
205
239
|
|
|
@@ -209,5 +243,7 @@ async function _issue({workflow, exchange, issueRequests, format} = {}) {
|
|
|
209
243
|
// filter out any undefined results, which are for results that were written
|
|
210
244
|
// to exchange variables and are not to be automatically returned in a
|
|
211
245
|
// presentation
|
|
212
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
credentials: results.filter(vc => vc), storedCredentials, exchangeChanged
|
|
248
|
+
};
|
|
213
249
|
}
|
|
@@ -19,7 +19,6 @@ const VALIDATORS = {
|
|
|
19
19
|
const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
|
|
20
20
|
|
|
21
21
|
bedrock.events.on('bedrock.init', () => {
|
|
22
|
-
// create validators for x-www-form-urlencoded parsed data
|
|
23
22
|
VALIDATORS.presentation = compile({schema: verifiablePresentationSchema()});
|
|
24
23
|
VALIDATORS.presentationSubmission = compile({
|
|
25
24
|
schema: presentationSubmissionSchema()
|
package/lib/oid4/http.js
CHANGED
|
@@ -227,9 +227,16 @@ export async function createRoutes({
|
|
|
227
227
|
check will ensure that the workflow used here only allows a single
|
|
228
228
|
credential request, indicating a single type. */
|
|
229
229
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
if(req.body?.format) {
|
|
231
|
+
// send OID4VCI draft 13 response
|
|
232
|
+
result = credentials.length === 1 ?
|
|
233
|
+
{format, credential: credentials[0]} : {format, credentials};
|
|
234
|
+
} else {
|
|
235
|
+
// send OID4VCI 1.0+ response
|
|
236
|
+
result = {
|
|
237
|
+
credentials: credentials.map(credential => ({credential}))
|
|
238
|
+
};
|
|
239
|
+
}
|
|
233
240
|
} catch(error) {
|
|
234
241
|
return _sendOID4Error({res, error});
|
|
235
242
|
}
|
|
@@ -264,6 +271,9 @@ export async function createRoutes({
|
|
|
264
271
|
// serve exchange ID as nonce
|
|
265
272
|
const exchangeRecord = await req.getExchange();
|
|
266
273
|
const {exchange} = exchangeRecord;
|
|
274
|
+
// FIXME: consider running inside of an exchange processor that would
|
|
275
|
+
// rotate the nonce (if it had already been used) and update the exchange
|
|
276
|
+
// when this endpoint is hit
|
|
267
277
|
res.json({c_nonce: exchange.id});
|
|
268
278
|
}));
|
|
269
279
|
|