@bedrock/vc-delivery 4.0.0 → 4.1.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/exchanges.js CHANGED
@@ -84,7 +84,7 @@ export async function insert({exchangerId, exchange}) {
84
84
  localExchangerId,
85
85
  meta,
86
86
  // possible states are: `pending`, `complete`, or `invalid`
87
- exchange: {...exchange, state: 'pending'}
87
+ exchange: {...exchange, sequence: 0, state: 'pending'}
88
88
  };
89
89
 
90
90
  // insert the exchange and get the updated record
@@ -153,6 +153,16 @@ export async function get({exchangerId, id, explain = false} = {}) {
153
153
  });
154
154
  }
155
155
 
156
+ // backwards compatibility; initialize `sequence`
157
+ if(record.exchange.sequence === undefined) {
158
+ await collection.updateOne({
159
+ localExchangerId,
160
+ 'exchange.id': id,
161
+ 'exchange.sequence': null
162
+ }, {$set: {'exchange.sequence': 0}});
163
+ record.exchange.sequence = 0;
164
+ }
165
+
156
166
  return record;
157
167
  }
158
168
 
@@ -163,29 +173,25 @@ export async function get({exchangerId, id, explain = false} = {}) {
163
173
  * @param {string} options.exchangerId - The ID of the exchanger the exchange
164
174
  * is associated with.
165
175
  * @param {object} options.exchange - The exchange to update.
166
- * @param {string} [options.expectedStep] - The expected current step in the
167
- * exchange.
168
176
  * @param {boolean} [options.explain=false] - An optional explain boolean.
169
177
  *
170
178
  * @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
171
179
  * success or an ExplainObject if `explain=true`.
172
180
  */
173
- export async function update({
174
- exchangerId, exchange, expectedStep, explain = false
175
- } = {}) {
181
+ export async function update({exchangerId, exchange, explain = false} = {}) {
176
182
  assert.string(exchangerId, 'exchangerId');
177
183
  assert.object(exchange, 'exchange');
178
- assert.optionalString(expectedStep, expectedStep);
179
184
  const {id} = exchange;
180
185
 
181
186
  // build update
182
187
  const now = Date.now();
183
188
  const update = {
189
+ $inc: {'exchange.sequence': 1},
184
190
  $set: {'meta.updated': now}
185
191
  };
186
- // update exchange `variables.results[step]`, `step`, and `ttl`
187
- if(exchange.variables?.results) {
188
- update.$set['exchange.variables.results'] = exchange.variables.results;
192
+ // update exchange `variables`, `step`, and `ttl`
193
+ if(exchange.variables) {
194
+ update.$set['exchange.variables'] = exchange.variables;
189
195
  }
190
196
  if(exchange.step !== undefined) {
191
197
  update.$set['exchange.step'] = exchange.step;
@@ -200,15 +206,11 @@ export async function update({
200
206
  const query = {
201
207
  localExchangerId,
202
208
  'exchange.id': id,
209
+ // exchange sequence must match previous sequence
210
+ 'exchange.sequence': exchange.sequence - 1,
203
211
  // previous state must be `pending` in order to update it
204
212
  'exchange.state': 'pending'
205
213
  };
206
- // current step must match previous step
207
- if(expectedStep === undefined) {
208
- query['exchange.step'] = null;
209
- } else {
210
- query['exchange.step'] = expectedStep;
211
- }
212
214
 
213
215
  if(explain) {
214
216
  // 'find().limit(1)' is used here because 'updateOne()' doesn't return a
@@ -259,24 +261,20 @@ export async function update({
259
261
  * @param {string} options.exchangerId - The ID of the exchanger the exchange
260
262
  * is associated with.
261
263
  * @param {object} options.exchange - The exchange to mark as complete.
262
- * @param {string} [options.expectedStep] - The expected current step in the
263
- * exchange.
264
264
  * @param {boolean} [options.explain=false] - An optional explain boolean.
265
265
  *
266
266
  * @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
267
267
  * success or an ExplainObject if `explain=true`.
268
268
  */
269
- export async function complete({
270
- exchangerId, exchange, expectedStep, explain = false
271
- } = {}) {
269
+ export async function complete({exchangerId, exchange, explain = false} = {}) {
272
270
  assert.string(exchangerId, 'exchangerId');
273
271
  assert.object(exchange, 'exchange');
274
- assert.optionalString(expectedStep, expectedStep);
275
272
  const {id} = exchange;
276
273
 
277
274
  // build update
278
275
  const now = Date.now();
279
276
  const update = {
277
+ $inc: {'exchange.sequence': 1},
280
278
  $set: {
281
279
  'exchange.state': 'complete',
282
280
  'meta.updated': now
@@ -299,15 +297,11 @@ export async function complete({
299
297
  const query = {
300
298
  localExchangerId,
301
299
  'exchange.id': id,
300
+ // exchange sequence must match previous sequence
301
+ 'exchange.sequence': exchange.sequence - 1,
302
302
  // previous state must be `pending` in order to change to `complete`
303
303
  'exchange.state': 'pending'
304
304
  };
305
- // current step must match previous step
306
- if(expectedStep === undefined) {
307
- query['exchange.step'] = null;
308
- } else {
309
- query['exchange.step'] = expectedStep;
310
- }
311
305
 
312
306
  if(explain) {
313
307
  // 'find().limit(1)' is used here because 'updateOne()' doesn't return a
@@ -337,12 +331,29 @@ export async function complete({
337
331
  // exchange does not exist, a not found error will be automatically thrown
338
332
  const record = await get({exchangerId, id});
339
333
 
340
- /* Note: Here the exchange *does* exist, but was already completed. This is
341
- an error condition that must result in invalidating the exchange. */
334
+ /* Note: Here the exchange *does* exist, but couldn't be updated because
335
+ another process changed it. That change either left it in a still pending
336
+ state or it was already completed. If it was already completed, it is an
337
+ error condition that must result in invalidating the exchange. */
338
+ if(record.state === 'pending') {
339
+ // exchange still pending, another process updated it
340
+ throw new BedrockError('Could not update exchange; conflict error.', {
341
+ name: 'InvalidStateError',
342
+ details: {
343
+ public: true,
344
+ // this is a client-side conflict error
345
+ httpStatusCode: 409
346
+ }
347
+ });
348
+ }
342
349
 
343
- // invalidate exchange, but do not throw any error to client; only log it
344
- _invalidateExchange({record}).catch(
345
- error => logger.error(`Could not invalidate exchange "${id}".`, {error}));
350
+ // state is either `complete` or `invalid`, so throw duplicate completed
351
+ // exchange error and invalidate exchange if needed, but do not throw any
352
+ // error to client; only log it
353
+ if(record.state !== 'invalid') {
354
+ _invalidateExchange({record}).catch(
355
+ error => logger.error(`Could not invalidate exchange "${id}".`, {error}));
356
+ }
346
357
 
347
358
  // throw duplicate completed exchange error
348
359
  throw new BedrockError('Could not complete exchange; already completed.', {
package/lib/helpers.js CHANGED
@@ -11,11 +11,22 @@ import {ZcapClient} from '@digitalbazaar/ezcap';
11
11
 
12
12
  const {config} = bedrock;
13
13
 
14
- export async function evaluateTemplate({exchange, typedTemplate} = {}) {
14
+ export async function evaluateTemplate({
15
+ exchanger, exchange, typedTemplate
16
+ } = {}) {
15
17
  // run jsonata compiler; only `jsonata` template type is supported and this
16
18
  // assumes only this template type will be passed in
17
19
  const {template} = typedTemplate;
18
20
  const {variables = {}} = exchange;
21
+ // always include `globals` as keyword for self-referencing exchange info
22
+ variables.globals = {
23
+ exchanger: {
24
+ id: exchanger.id
25
+ },
26
+ exchange: {
27
+ id: exchange.id
28
+ }
29
+ };
19
30
  return jsonata(template).evaluate(variables, variables);
20
31
  }
21
32
 
package/lib/http.js CHANGED
@@ -8,7 +8,7 @@ import {createChallenge as _createChallenge, verify} from './verify.js';
8
8
  import {
9
9
  createExchangeBody, useExchangeBody
10
10
  } from '../schemas/bedrock-vc-exchanger.js';
11
- import {evaluateTemplate, generateRandom} from './helpers.js';
11
+ import {evaluateTemplate, generateRandom, getExchangerId} from './helpers.js';
12
12
  import {exportJWK, generateKeyPair, importJWK} from 'jose';
13
13
  import {metering, middleware} from '@bedrock/service-core';
14
14
  import {asyncHandler} from '@bedrock/express';
@@ -48,8 +48,7 @@ export async function addRoutes({app, service} = {}) {
48
48
  // used to fetch exchange record in parallel
49
49
  const getExchange = asyncHandler(async (req, res, next) => {
50
50
  const {localId, exchangeId: id} = req.params;
51
- const {baseUri} = bedrock.config.server;
52
- const exchangerId = `${baseUri}${routePrefix}/${localId}`;
51
+ const exchangerId = getExchangerId({routePrefix, localId});
53
52
  // expose access to result via `req`; do not wait for it to settle here
54
53
  const exchangePromise = exchanges.get({exchangerId, id}).catch(e => e);
55
54
  req.getExchange = async () => {
@@ -174,6 +173,7 @@ export async function addRoutes({app, service} = {}) {
174
173
  // process exchange step(s)
175
174
  let i = 0;
176
175
  let currentStep = exchange.step;
176
+ let step;
177
177
  while(true) {
178
178
  if(i++ > MAXIMUM_STEPS) {
179
179
  throw new BedrockError('Maximum steps exceeded.', {
@@ -188,12 +188,12 @@ export async function addRoutes({app, service} = {}) {
188
188
  }
189
189
 
190
190
  // get current step details
191
- let step = exchanger.steps[currentStep];
191
+ step = exchanger.steps[currentStep];
192
192
  if(step.stepTemplate) {
193
193
  // generate step from the template; assume the template type is
194
194
  // `jsonata` per the JSON schema
195
195
  step = await evaluateTemplate(
196
- {exchange, typedTemplate: step.stepTemplate});
196
+ {exchanger, exchange, typedTemplate: step.stepTemplate});
197
197
  if(Object.keys(step).length === 0) {
198
198
  throw new BedrockError('Empty step detected.', {
199
199
  name: 'DataError',
@@ -271,11 +271,9 @@ export async function addRoutes({app, service} = {}) {
271
271
 
272
272
  // update the exchange to go to the next step, then loop to send
273
273
  // next VPR
274
- exchange.step = step.nextStep;
275
- await exchanges.update({
276
- exchangerId: exchanger.id, exchange, expectedStep: currentStep
277
- });
278
- currentStep = exchange.step;
274
+ currentStep = exchange.step = step.nextStep;
275
+ exchange.sequence++;
276
+ await exchanges.update({exchangerId: exchanger.id, exchange});
279
277
 
280
278
  // FIXME: there may be VCs to issue during this step, do so before
281
279
  // sending the VPR above
@@ -290,9 +288,8 @@ export async function addRoutes({app, service} = {}) {
290
288
  }
291
289
 
292
290
  // mark exchange complete
293
- await exchanges.complete({
294
- exchangerId: exchanger.id, exchange, expectedStep: currentStep
295
- });
291
+ exchange.sequence++;
292
+ await exchanges.complete({exchangerId: exchanger.id, exchange});
296
293
 
297
294
  // FIXME: decide what the best recovery path is if delivery fails (but no
298
295
  // replay attack detected) after exchange has been marked complete
@@ -301,6 +298,11 @@ export async function addRoutes({app, service} = {}) {
301
298
  // VCs to issue
302
299
  const result = await issue({exchanger, exchange});
303
300
 
301
+ // if last `step` has a redirect URL, include it in the response
302
+ if(step?.redirectUrl) {
303
+ result.redirectUrl = step.redirectUrl;
304
+ }
305
+
304
306
  // send result
305
307
  res.json(result);
306
308
  }));
package/lib/issue.js CHANGED
@@ -16,7 +16,7 @@ export async function issue({exchanger, exchange} = {}) {
16
16
 
17
17
  // evaluate template
18
18
  const credentials = await Promise.all(credentialTemplates.map(
19
- typedTemplate => evaluateTemplate({exchange, typedTemplate})));
19
+ typedTemplate => evaluateTemplate({exchanger, exchange, typedTemplate})));
20
20
  // issue all VCs
21
21
  const vcs = await _issue({exchanger, credentials});
22
22
  verifiableCredential.push(...vcs);
package/lib/openId.js CHANGED
@@ -1,19 +1,30 @@
1
1
  /*!
2
2
  * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
+ import * as bedrock from '@bedrock/core';
4
5
  import * as exchanges from './exchanges.js';
6
+ import {
7
+ compile, schemas, createValidateMiddleware as validate
8
+ } from '@bedrock/validation';
5
9
  import {importJWK, SignJWT} from 'jose';
6
10
  import {
7
- openIdBatchCredentialBody, openIdCredentialBody, openIdTokenBody
11
+ openIdAuthorizationResponseBody, openIdBatchCredentialBody,
12
+ openIdCredentialBody, openIdTokenBody,
13
+ presentationSubmission as presentationSubmissionSchema
8
14
  } from '../schemas/bedrock-vc-exchanger.js';
15
+ import {verify, verifyDidProofJwt} from './verify.js';
9
16
  import {asyncHandler} from '@bedrock/express';
10
17
  import bodyParser from 'body-parser';
11
18
  import {checkAccessToken} from '@bedrock/oauth2-verifier';
12
19
  import cors from 'cors';
20
+ import {evaluateTemplate} from './helpers.js';
13
21
  import {issue} from './issue.js';
22
+ import {klona} from 'klona';
23
+ import {oid4vp} from '@digitalbazaar/oid4-client';
14
24
  import {timingSafeEqual} from 'node:crypto';
15
- import {createValidateMiddleware as validate} from '@bedrock/validation';
16
- import {verifyDidProofJwt} from './verify.js';
25
+ import {UnsecuredJWT} from 'jose';
26
+
27
+ const {util: {BedrockError}} = bedrock;
17
28
 
18
29
  /* NOTE: Parts of the OID4VCI design imply tight integration between the
19
30
  authorization server and the credential issuance / delivery server. This
@@ -50,17 +61,27 @@ export async function createRoutes({
50
61
  } = {}) {
51
62
  const openIdRoute = `${exchangeRoute}/openid`;
52
63
  const routes = {
64
+ // OID4VCI routes
53
65
  asMetadata: `/.well-known/oauth-authorization-server${exchangeRoute}`,
54
66
  ciMetadata: `/.well-known/openid-credential-issuer${exchangeRoute}`,
55
67
  batchCredential: `${openIdRoute}/batch_credential`,
56
68
  credential: `${openIdRoute}/credential`,
57
69
  token: `${openIdRoute}/token`,
58
- jwks: `${openIdRoute}/jwks`
70
+ jwks: `${openIdRoute}/jwks`,
71
+ // OID4VP routes
72
+ authorizationRequest: `${openIdRoute}/client/authorization/request`,
73
+ authorizationResponse: `${openIdRoute}/client/authorization/response`
59
74
  };
60
75
 
61
76
  // urlencoded body parser (extended=true for rich JSON-like representation)
62
77
  const urlencoded = bodyParser.urlencoded({extended: true});
63
78
 
79
+ // create validators for x-www-form-urlencoded parsed data
80
+ const validatePresentation = compile(
81
+ {schema: schemas.verifiablePresentation()});
82
+ const validatePresentationSubmission = compile(
83
+ {schema: presentationSubmissionSchema});
84
+
64
85
  // an authorization server meta data endpoint
65
86
  // serves `.well-known` oauth2 AS config for each exchange; each config is
66
87
  // based on the exchanger used to create the exchange
@@ -118,9 +139,7 @@ export async function createRoutes({
118
139
  asyncHandler(async (req, res) => {
119
140
  const {exchange} = await req.getExchange();
120
141
  if(!exchange.openId) {
121
- // FIXME: improve error
122
- // unsupported protocol for the exchange
123
- throw new Error('unsupported protocol');
142
+ _throwUnsupportedProtocol();
124
143
  }
125
144
  // serve exchange's public key
126
145
  res.json({keys: [exchange.openId.oauth2.keyPair.publicKeyJwk]});
@@ -141,9 +160,7 @@ export async function createRoutes({
141
160
  const exchangeRecord = await req.getExchange();
142
161
  const {exchange} = exchangeRecord;
143
162
  if(!exchange.openId) {
144
- // FIXME: improve error
145
- // unsupported protocol for the exchange
146
- throw new Error('unsupported protocol');
163
+ _throwUnsupportedProtocol();
147
164
  }
148
165
 
149
166
  /* Examples of types of token requests:
@@ -167,7 +184,7 @@ export async function createRoutes({
167
184
  if(grantType !== PRE_AUTH_GRANT_TYPE) {
168
185
  // unsupported grant type
169
186
  // FIXME: throw proper oauth2 formatted error
170
- throw new Error('unsupported grant type');
187
+ throw new Error('Unsupported grant type.');
171
188
  }
172
189
 
173
190
  // validate grant type
@@ -298,6 +315,48 @@ export async function createRoutes({
298
315
  vc => ({format: 'ldp_vc', credential: vc}));
299
316
  res.json({credential_responses: responses});
300
317
  }));
318
+
319
+ // an OID4VP verifier endpoint
320
+ // serves the authorization request, including presentation definition
321
+ // associated with the current step in the exchange
322
+ app.get(
323
+ routes.authorizationRequest,
324
+ cors(),
325
+ getConfigMiddleware,
326
+ getExchange,
327
+ asyncHandler(async (req, res) => {
328
+ const {authorizationRequest} = await _getAuthorizationRequest({req});
329
+ // construct and send authz request as unsecured JWT
330
+ const jwt = new UnsecuredJWT(authorizationRequest).encode();
331
+ res.set('content-type', 'application/oauth-authz-req+jwt');
332
+ res.send(jwt);
333
+ }));
334
+
335
+ // an OID4VP verifier endpoint
336
+ // receives an authorization response with vp_token
337
+ app.options(routes.authorizationResponse, cors());
338
+ app.post(
339
+ routes.authorizationResponse,
340
+ cors(),
341
+ urlencoded,
342
+ validate({bodySchema: openIdAuthorizationResponseBody()}),
343
+ getConfigMiddleware,
344
+ getExchange,
345
+ asyncHandler(async (req, res) => {
346
+ // get JSON `vp_token` and `presentation_submission`
347
+ const {vp_token, presentation_submission} = req.body;
348
+
349
+ // JSON parse and validate `vp_token` and `presentation_submission`
350
+ const presentation = _jsonParse(vp_token, 'vp_token');
351
+ const presentationSubmission = _jsonParse(
352
+ presentation_submission, 'presentation_submission');
353
+ _validate(validatePresentationSubmission, presentationSubmission);
354
+ _validate(validatePresentation, presentation);
355
+
356
+ const result = await _processAuthorizationResponse(
357
+ {req, presentation, presentationSubmission});
358
+ res.json(result);
359
+ }));
301
360
  }
302
361
 
303
362
  async function _createExchangeAccessToken({exchanger, exchangeRecord}) {
@@ -394,9 +453,7 @@ async function _processCredentialRequests({req, res, isBatchRequest}) {
394
453
  const exchangeRecord = await req.getExchange();
395
454
  const {exchange} = exchangeRecord;
396
455
  if(!exchange.openId) {
397
- // FIXME: improve error
398
- // unsupported protocol for the exchange
399
- throw new Error('unsupported protocol');
456
+ _throwUnsupportedProtocol();
400
457
  }
401
458
 
402
459
  // ensure oauth2 access token is valid
@@ -458,9 +515,8 @@ async function _processCredentialRequests({req, res, isBatchRequest}) {
458
515
  }
459
516
 
460
517
  // mark exchange complete
461
- await exchanges.complete({
462
- exchangerId: exchanger.id, exchange, expectedStep: currentStep
463
- });
518
+ exchange.sequence++;
519
+ await exchanges.complete({exchangerId: exchanger.id, exchange});
464
520
 
465
521
  // FIXME: decide what the best recovery path is if delivery fails (but no
466
522
  // replay attack detected) after exchange has been marked complete
@@ -502,6 +558,115 @@ async function _requestDidProof({res, exchangeRecord}) {
502
558
  });
503
559
  }
504
560
 
561
+ async function _getAuthorizationRequest({req}) {
562
+ const {config: exchanger} = req.serviceObject;
563
+ const exchangeRecord = await req.getExchange();
564
+ let {exchange} = exchangeRecord;
565
+ let step;
566
+
567
+ while(true) {
568
+ // exchange step required for OID4VP
569
+ const currentStep = exchange.step;
570
+ if(!currentStep) {
571
+ _throwUnsupportedProtocol();
572
+ }
573
+
574
+ step = exchanger.steps[exchange.step];
575
+ if(step.stepTemplate) {
576
+ // generate step from the template; assume the template type is
577
+ // `jsonata` per the JSON schema
578
+ step = await evaluateTemplate(
579
+ {exchanger, exchange, typedTemplate: step.stepTemplate});
580
+ if(Object.keys(step).length === 0) {
581
+ throw new BedrockError('Could not create authorization request.', {
582
+ name: 'DataError',
583
+ details: {httpStatusCode: 500, public: true}
584
+ });
585
+ }
586
+ }
587
+
588
+ // step must have `openId` to perform OID4VP
589
+ if(!step.openId) {
590
+ _throwUnsupportedProtocol();
591
+ }
592
+
593
+ // get authorization request
594
+ let authorizationRequest = step.openId.authorizationRequest;
595
+ if(!authorizationRequest) {
596
+ // create authorization request...
597
+ // get variable name for authorization request
598
+ const authzReqName = step.openId.createAuthorizationRequest;
599
+ if(authzReqName === undefined) {
600
+ _throwUnsupportedProtocol();
601
+ }
602
+
603
+ // create or get cached authorization request
604
+ authorizationRequest = exchange.variables?.[authzReqName];
605
+ if(!authorizationRequest) {
606
+ const {verifiablePresentationRequest} = step;
607
+ authorizationRequest = oid4vp.fromVpr({verifiablePresentationRequest});
608
+
609
+ // add / override params from step `openId` information
610
+ const {
611
+ client_id, client_id_scheme,
612
+ client_metadata, client_metadata_uri,
613
+ nonce, response_uri
614
+ } = step.openId || {};
615
+ if(client_id) {
616
+ authorizationRequest.client_id = client_id;
617
+ }
618
+ if(client_id_scheme) {
619
+ authorizationRequest.client_id_scheme = client_id_scheme;
620
+ }
621
+ if(client_metadata) {
622
+ authorizationRequest.client_metadata = klona(client_metadata);
623
+ } else if(client_metadata_uri) {
624
+ authorizationRequest.client_metadata_uri = client_metadata_uri;
625
+ }
626
+ if(nonce) {
627
+ authorizationRequest.nonce = nonce;
628
+ } else if(authorizationRequest.nonce === undefined) {
629
+ // if no nonce has been set for the authorization request, use the
630
+ // exchange ID
631
+ authorizationRequest.nonce = exchange.id;
632
+ }
633
+ if(response_uri) {
634
+ authorizationRequest.response_uri = response_uri;
635
+ } else if(authorizationRequest.response_mode === 'direct_post' &&
636
+ authorizationRequest.client_id_scheme === 'redirect_uri') {
637
+ // `authorizationRequest` uses `direct_post` so force client ID to
638
+ // be the exchange response URL per "Note" here:
639
+ // eslint-disable-next-line max-len
640
+ // https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#section-6.2
641
+ authorizationRequest.response_uri = authorizationRequest.client_id;
642
+ }
643
+
644
+ // store generated authorization request
645
+ if(!exchange.variables) {
646
+ exchange.variables = {};
647
+ }
648
+ exchange.variables[authzReqName] = authorizationRequest;
649
+ exchange.sequence++;
650
+ try {
651
+ await exchanges.update({exchangerId: exchanger.id, exchange});
652
+ } catch(e) {
653
+ if(e.name !== 'InvalidStateError') {
654
+ // unrecoverable error
655
+ throw e;
656
+ }
657
+ // get exchange and loop to try again on `InvalidStateError`
658
+ const record = await exchanges.get(
659
+ {exchangerId: exchanger.id, id: exchange.id});
660
+ ({exchange} = record);
661
+ continue;
662
+ }
663
+ }
664
+ }
665
+
666
+ return {authorizationRequest, exchange, step};
667
+ }
668
+ }
669
+
505
670
  function _assertCredentialRequests({
506
671
  credentialRequests, expectedCredentialRequests
507
672
  }) {
@@ -525,3 +690,84 @@ function _matchCredentialRequest(a, b) {
525
690
  return (c1.length === c2.length && t1.length === t2.length &&
526
691
  c1.every((c, i) => c === c2[i]) && t1.every(t => t2.some(x => t === x)));
527
692
  }
693
+
694
+ async function _processAuthorizationResponse({
695
+ req, presentation, presentationSubmission
696
+ }) {
697
+ const {config: exchanger} = req.serviceObject;
698
+ const exchangeRecord = await req.getExchange();
699
+ let {exchange} = exchangeRecord;
700
+
701
+ // get authorization request and updated exchange associated with exchange
702
+ const arRequest = await _getAuthorizationRequest({req});
703
+ const {authorizationRequest, step} = arRequest;
704
+ ({exchange} = arRequest);
705
+
706
+ // verify the received VP
707
+ const {verifiablePresentationRequest} = await oid4vp.toVpr(
708
+ {authorizationRequest});
709
+ const {verificationMethod} = await verify({
710
+ exchanger,
711
+ verifiablePresentationRequest,
712
+ presentation,
713
+ expectedChallenge: authorizationRequest.nonce
714
+ });
715
+
716
+ // FIXME: check the VP against the presentation submission if requested
717
+
718
+ // store VP results in variables associated with current step
719
+ const currentStep = exchange.step;
720
+ if(!exchange.variables.results) {
721
+ exchange.variables.results = {};
722
+ }
723
+ exchange.variables.results[currentStep] = {
724
+ // common use case of DID Authentication; provide `did` for ease
725
+ // of use in template
726
+ did: verificationMethod.controller,
727
+ verificationMethod,
728
+ verifiablePresentation: presentation,
729
+ openId: {
730
+ authorizationRequest,
731
+ presentationSubmission
732
+ }
733
+ };
734
+
735
+ // mark exchange complete
736
+ exchange.sequence++;
737
+ await exchanges.complete({exchangerId: exchanger.id, exchange});
738
+
739
+ const result = {};
740
+
741
+ // include `redirect_uri` if specified in step
742
+ const redirect_uri = step.openId?.redirect_uri;
743
+ if(redirect_uri) {
744
+ result.redirect_uri = redirect_uri;
745
+ }
746
+
747
+ return result;
748
+ }
749
+
750
+ function _jsonParse(x, name) {
751
+ try {
752
+ return JSON.parse(x);
753
+ } catch(cause) {
754
+ throw new BedrockError(`Could not parse "${name}".`, {
755
+ name: 'DataError',
756
+ details: {httpStatusCode: 400, public: true},
757
+ cause
758
+ });
759
+ }
760
+ }
761
+
762
+ function _throwUnsupportedProtocol() {
763
+ // FIXME: improve error
764
+ // unsupported protocol for the exchange
765
+ throw new Error('Unsupported protocol.');
766
+ }
767
+
768
+ function _validate(validator, data) {
769
+ const result = validator(data);
770
+ if(!result.valid) {
771
+ throw result.error;
772
+ }
773
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrock/vc-delivery",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "type": "module",
5
5
  "description": "Bedrock Verifiable Credential Delivery",
6
6
  "main": "./lib/index.js",
@@ -38,6 +38,7 @@
38
38
  "@digitalbazaar/ed25519-signature-2020": "^5.2.0",
39
39
  "@digitalbazaar/ed25519-verification-key-2020": "^4.1.0",
40
40
  "@digitalbazaar/ezcap": "^4.0.0",
41
+ "@digitalbazaar/oid4-client": "^3.1.0",
41
42
  "@digitalbazaar/vc": "^6.0.1",
42
43
  "assert-plus": "^1.0.0",
43
44
  "bnid": "^3.0.0",
@@ -147,7 +147,8 @@ const step = {
147
147
  'createChallenge',
148
148
  'verifiablePresentationRequest',
149
149
  'jwtDidProofRequest',
150
- 'nextStep'
150
+ 'nextStep',
151
+ 'openId'
151
152
  ]
152
153
  }
153
154
  }, {
@@ -195,7 +196,37 @@ const step = {
195
196
  nextStep: {
196
197
  type: 'string'
197
198
  },
198
- stepTemplate: typedTemplate
199
+ stepTemplate: typedTemplate,
200
+ // required to support OID4VP (but can be provided by step template instead)
201
+ openId: {
202
+ type: 'object',
203
+ additionalProperties: false,
204
+ // an authorization request or a directive to create one can be used,
205
+ // but not both
206
+ oneOf: [{
207
+ required: ['createAuthorizationRequest'],
208
+ // cannot also use `authorizationRequest
209
+ not: {
210
+ required: ['authorizationRequest']
211
+ }
212
+ }, {
213
+ required: ['authorizationRequest'],
214
+ // cannot also use `createAuthorizationRequest`
215
+ not: {
216
+ required: ['createAuthorizationRequest']
217
+ }
218
+ }],
219
+ properties: {
220
+ // value is name of variable to store the created authz request in
221
+ createAuthorizationRequest: {
222
+ type: 'string'
223
+ },
224
+ // ... or full authz request to use
225
+ authorizationRequest: {
226
+ type: 'object'
227
+ }
228
+ }
229
+ }
199
230
  }
200
231
  };
201
232
 
@@ -301,3 +332,67 @@ export const openIdTokenBody = {
301
332
  // }
302
333
  }
303
334
  };
335
+
336
+ const presentationDescriptor = {
337
+ title: 'Presentation Submission Descriptor',
338
+ type: 'object',
339
+ additionalProperties: false,
340
+ required: ['id', 'format', 'path'],
341
+ properties: {
342
+ id: {
343
+ type: 'string'
344
+ },
345
+ format: {
346
+ type: 'string'
347
+ },
348
+ path: {
349
+ type: 'string'
350
+ },
351
+ path_nested: {
352
+ type: 'object'
353
+ }
354
+ }
355
+ };
356
+
357
+ export const presentationSubmission = {
358
+ title: 'Presentation Submission',
359
+ type: 'object',
360
+ additionalProperties: false,
361
+ required: ['id', 'definition_id', 'descriptor_map'],
362
+ properties: {
363
+ id: {
364
+ type: 'string'
365
+ },
366
+ definition_id: {
367
+ type: 'string'
368
+ },
369
+ descriptor_map: {
370
+ title: 'Presentation Submission Descriptor Map',
371
+ type: 'array',
372
+ minItems: 0,
373
+ items: presentationDescriptor
374
+ }
375
+ }
376
+ };
377
+
378
+ export function openIdAuthorizationResponseBody() {
379
+ return {
380
+ title: 'OID4VP Authorization Response',
381
+ type: 'object',
382
+ additionalProperties: false,
383
+ required: ['presentation_submission', 'vp_token'],
384
+ properties: {
385
+ // is a JSON string in the x-www-form-urlencoded body
386
+ presentation_submission: {
387
+ type: 'string'
388
+ },
389
+ // is a JSON string in the x-www-form-urlencoded body
390
+ vp_token: {
391
+ type: 'string'
392
+ },
393
+ state: {
394
+ type: 'string'
395
+ }
396
+ }
397
+ };
398
+ }