@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.
@@ -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 _getStep({workflow, exchange});
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 _updateExchange({workflow, exchange, meta, step});
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 _updateExchange({workflow, exchange, meta, step});
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 _updateExchange({workflow, exchange, meta, step});
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
- retryState.canRetry = !issuanceTriggered;
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, format = 'application/vc',
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, format});
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({workflow, zcaps, format}) {
157
- const issuerInstances = getWorkflowIssuerInstances({workflow});
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
- ({supportedFormats}) => supportedFormats.includes(format));
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, format} = {}) {
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 the zcap to use for the issue requests
168
- const capability = _getIssueZcap({workflow, zcaps, format});
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 {credentials: results.filter(vc => vc), exchangeChanged};
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
- // send OID4VCI response
231
- result = credentials.length === 1 ?
232
- {format, credential: credentials[0]} : {format, credentials};
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