@bedrock/vc-delivery 5.1.0 → 5.3.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 +185 -48
- package/lib/helpers.js +16 -0
- package/lib/http.js +2 -2
- package/lib/oid4/http.js +13 -0
- package/lib/oid4/oid4vci.js +195 -128
- package/lib/oid4/oid4vp.js +101 -77
- package/lib/vcapi.js +176 -146
- package/lib/verify.js +5 -17
- package/package.json +3 -2
package/lib/exchanges.js
CHANGED
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as database from '@bedrock/mongodb';
|
|
6
|
+
import {parseLocalId, stripStacktrace} from './helpers.js';
|
|
6
7
|
import assert from 'assert-plus';
|
|
7
8
|
import {logger} from './logger.js';
|
|
8
|
-
import {
|
|
9
|
+
import {serializeError} from 'serialize-error';
|
|
9
10
|
|
|
10
11
|
const {util: {BedrockError}} = bedrock;
|
|
11
12
|
|
|
12
|
-
/* Note: Exchanges
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Exchanges are always in one of three states: `pending`, `complete`, or
|
|
16
|
-
`invalid`. They can only transistion from `pending` to `complete` or from
|
|
17
|
-
`complete` to `invalid`.
|
|
13
|
+
/* Note: Exchanges have default TTLs of 15 minutes and are always in one of
|
|
14
|
+
four states: `pending`, `active`, `complete`, or `invalid`. They can only
|
|
15
|
+
transition from `pending` to `complete` or from `complete` to `invalid`.
|
|
18
16
|
|
|
19
17
|
If an exchange is marked as complete, any attempt to mark it complete again
|
|
20
18
|
will result in an action, as specified in the exchange record, being taken
|
|
@@ -28,6 +26,15 @@ provided during the exchange a capability to verify them must be provided. */
|
|
|
28
26
|
|
|
29
27
|
const COLLECTION_NAME = 'vc-exchange';
|
|
30
28
|
|
|
29
|
+
// allow updates to the last error every 500ms
|
|
30
|
+
const LAST_ERROR_UPDATE_CONSTRAINTS = {
|
|
31
|
+
// if the exchange has been updated 5 or more times, apply the time limit
|
|
32
|
+
sequenceThreshold: 5,
|
|
33
|
+
// 1 second must expire before updating last error once sequence threshold
|
|
34
|
+
// has been hit
|
|
35
|
+
updateTimeLimit: 1000
|
|
36
|
+
};
|
|
37
|
+
|
|
31
38
|
bedrock.events.on('bedrock-mongodb.ready', async () => {
|
|
32
39
|
await database.openCollections([COLLECTION_NAME]);
|
|
33
40
|
|
|
@@ -88,9 +95,14 @@ export async function insert({workflowId, exchange}) {
|
|
|
88
95
|
// build exchange record
|
|
89
96
|
const now = Date.now();
|
|
90
97
|
const meta = {created: now, updated: now};
|
|
98
|
+
// possible states are: `pending`, `active`, `complete`, or `invalid`
|
|
99
|
+
exchange = {...exchange, sequence: 0, state: 'pending'};
|
|
91
100
|
if(exchange.ttl !== undefined) {
|
|
92
|
-
// TTL is in seconds
|
|
93
|
-
|
|
101
|
+
// TTL is in seconds, convert to `expires`
|
|
102
|
+
const expires = new Date(now + exchange.ttl * 1000);
|
|
103
|
+
meta.expires = expires;
|
|
104
|
+
exchange.expires = expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
105
|
+
delete exchange.ttl;
|
|
94
106
|
}
|
|
95
107
|
const {localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
96
108
|
const record = {
|
|
@@ -98,8 +110,7 @@ export async function insert({workflowId, exchange}) {
|
|
|
98
110
|
// backwards compatibility: enable existing systems to find record
|
|
99
111
|
localExchangerId: localWorkflowId,
|
|
100
112
|
meta,
|
|
101
|
-
|
|
102
|
-
exchange: {...exchange, sequence: 0, state: 'pending'}
|
|
113
|
+
exchange
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
// insert the exchange and get the updated record
|
|
@@ -129,12 +140,16 @@ export async function insert({workflowId, exchange}) {
|
|
|
129
140
|
* @param {string} options.workflowId - The ID of the workflow that the
|
|
130
141
|
* exchange is associated with.
|
|
131
142
|
* @param {string} options.id - The ID of the exchange to retrieve.
|
|
143
|
+
* @param {boolean} [options.allowExpired=false] - Controls whether an expired
|
|
144
|
+
* exchange that is still in the database can be retrieved or not.
|
|
132
145
|
* @param {boolean} [options.explain=false] - An optional explain boolean.
|
|
133
146
|
*
|
|
134
147
|
* @returns {Promise<object | ExplainObject>} Resolves with the record that
|
|
135
148
|
* matches the query or an ExplainObject if `explain=true`.
|
|
136
149
|
*/
|
|
137
|
-
export async function get({
|
|
150
|
+
export async function get({
|
|
151
|
+
workflowId, id, allowExpired = false, explain = false
|
|
152
|
+
} = {}) {
|
|
138
153
|
assert.string(workflowId, 'workflowId');
|
|
139
154
|
assert.string(id, 'id');
|
|
140
155
|
|
|
@@ -160,7 +175,16 @@ export async function get({workflowId, id, explain = false} = {}) {
|
|
|
160
175
|
return cursor.explain('executionStats');
|
|
161
176
|
}
|
|
162
177
|
|
|
163
|
-
|
|
178
|
+
let record = await collection.findOne(query, {projection});
|
|
179
|
+
if(record?.exchange.expires && !allowExpired) {
|
|
180
|
+
// ensure `expires` is enforced programmatically even if background job
|
|
181
|
+
// has not yet removed the record
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const expires = new Date(record.exchange.expires);
|
|
184
|
+
if(now >= expires) {
|
|
185
|
+
record = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
164
188
|
if(!record) {
|
|
165
189
|
throw new BedrockError('Exchange not found.', {
|
|
166
190
|
name: 'NotFoundError',
|
|
@@ -196,8 +220,8 @@ export async function get({workflowId, id, explain = false} = {}) {
|
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
/**
|
|
199
|
-
* Updates a pending exchange with new state, variables, step, and
|
|
200
|
-
* information.
|
|
223
|
+
* Updates a pending or active exchange with new state, variables, step, and
|
|
224
|
+
* TTL, and error information.
|
|
201
225
|
*
|
|
202
226
|
* @param {object} options - The options to use.
|
|
203
227
|
* @param {string} options.workflowId - The ID of the workflow the exchange
|
|
@@ -214,21 +238,7 @@ export async function update({workflowId, exchange, explain = false} = {}) {
|
|
|
214
238
|
const {id} = exchange;
|
|
215
239
|
|
|
216
240
|
// build update
|
|
217
|
-
const
|
|
218
|
-
const update = {
|
|
219
|
-
$inc: {'exchange.sequence': 1},
|
|
220
|
-
$set: {'exchange.state': exchange.state, 'meta.updated': now}
|
|
221
|
-
};
|
|
222
|
-
// update exchange `variables`, `step`, and `ttl`
|
|
223
|
-
if(exchange.variables) {
|
|
224
|
-
update.$set['exchange.variables'] = exchange.variables;
|
|
225
|
-
}
|
|
226
|
-
if(exchange.step !== undefined) {
|
|
227
|
-
update.$set['exchange.step'] = exchange.step;
|
|
228
|
-
}
|
|
229
|
-
if(exchange.ttl !== undefined) {
|
|
230
|
-
update.$set['exchange.ttl'] = exchange.ttl;
|
|
231
|
-
}
|
|
241
|
+
const update = _buildUpdate({exchange, complete: false});
|
|
232
242
|
|
|
233
243
|
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
234
244
|
|
|
@@ -304,27 +314,13 @@ export async function update({workflowId, exchange, explain = false} = {}) {
|
|
|
304
314
|
export async function complete({workflowId, exchange, explain = false} = {}) {
|
|
305
315
|
assert.string(workflowId, 'workflowId');
|
|
306
316
|
assert.object(exchange, 'exchange');
|
|
317
|
+
if(exchange.state !== 'complete') {
|
|
318
|
+
throw new Error('"exchange.state" must be set to "complete".');
|
|
319
|
+
}
|
|
307
320
|
const {id} = exchange;
|
|
308
321
|
|
|
309
322
|
// build update
|
|
310
|
-
const
|
|
311
|
-
const update = {
|
|
312
|
-
$inc: {'exchange.sequence': 1},
|
|
313
|
-
$set: {
|
|
314
|
-
'exchange.state': 'complete',
|
|
315
|
-
'meta.updated': now
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
// update exchange `variables.results[step]`, `step`, and `ttl`
|
|
319
|
-
if(exchange.variables?.results) {
|
|
320
|
-
update.$set['exchange.variables.results'] = exchange.variables.results;
|
|
321
|
-
}
|
|
322
|
-
if(exchange.step !== undefined) {
|
|
323
|
-
update.$set['exchange.step'] = exchange.step;
|
|
324
|
-
}
|
|
325
|
-
if(exchange.ttl !== undefined) {
|
|
326
|
-
update.$set['exchange.ttl'] = exchange.ttl;
|
|
327
|
-
}
|
|
323
|
+
const update = _buildUpdate({exchange, complete: true});
|
|
328
324
|
|
|
329
325
|
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
330
326
|
|
|
@@ -408,6 +404,105 @@ export async function complete({workflowId, exchange, explain = false} = {}) {
|
|
|
408
404
|
});
|
|
409
405
|
}
|
|
410
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Sets the last error associated with an exchange, provided that the exchange
|
|
409
|
+
* has not been recently or frequently updated.
|
|
410
|
+
*
|
|
411
|
+
* @param {object} options - The options to use.
|
|
412
|
+
* @param {string} options.workflowId - The ID of the workflow the exchange
|
|
413
|
+
* is associated with.
|
|
414
|
+
* @param {object} options.exchange - The exchange to update with `lastError`
|
|
415
|
+
* set.
|
|
416
|
+
* @param {object} options.lastUpdated - The last update time (in milliseconds).
|
|
417
|
+
* @param {boolean} [options.explain=false] - An optional explain boolean.
|
|
418
|
+
*
|
|
419
|
+
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
|
|
420
|
+
* success or an ExplainObject if `explain=true`.
|
|
421
|
+
*/
|
|
422
|
+
export async function setLastError({
|
|
423
|
+
workflowId, exchange, lastUpdated, explain = false
|
|
424
|
+
} = {}) {
|
|
425
|
+
assert.string(workflowId, 'workflowId');
|
|
426
|
+
assert.object(exchange, 'exchange');
|
|
427
|
+
assert.object(exchange.lastError, 'exchange.lastError');
|
|
428
|
+
assert.number(lastUpdated, 'lastUpdate');
|
|
429
|
+
|
|
430
|
+
// prevent too many updates to an exchange to write the last error to it
|
|
431
|
+
// by limiting to a few
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
if(exchange.sequence > LAST_ERROR_UPDATE_CONSTRAINTS.sequenceThreshold &&
|
|
434
|
+
now < (lastUpdated + LAST_ERROR_UPDATE_CONSTRAINTS.updateTimeLimit)) {
|
|
435
|
+
// deny update, too many last error updates
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// build update
|
|
440
|
+
const update = {
|
|
441
|
+
$inc: {'exchange.sequence': 1},
|
|
442
|
+
$set: {
|
|
443
|
+
'meta.updated': now,
|
|
444
|
+
'exchange.lastError': serializeError(stripStacktrace(exchange.lastError))
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
449
|
+
|
|
450
|
+
const {id} = exchange;
|
|
451
|
+
const collection = database.collections[COLLECTION_NAME];
|
|
452
|
+
const query = {
|
|
453
|
+
localWorkflowId,
|
|
454
|
+
'exchange.id': id,
|
|
455
|
+
// exchange sequence must match previous sequence
|
|
456
|
+
'exchange.sequence': exchange.sequence - 1
|
|
457
|
+
};
|
|
458
|
+
// backwards compatibility: query on `localExchangerId`
|
|
459
|
+
if(base.endsWith('/exchangers')) {
|
|
460
|
+
query.localWorkflowId = {$in: [null, localWorkflowId]};
|
|
461
|
+
query.localExchangerId = localWorkflowId;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if(explain) {
|
|
465
|
+
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
|
|
466
|
+
// cursor which allows the use of the explain function.
|
|
467
|
+
const cursor = await collection.find(query).limit(1);
|
|
468
|
+
return cursor.explain('executionStats');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await collection.updateOne(query, update);
|
|
473
|
+
if(result.result.n > 0) {
|
|
474
|
+
// document modified: success
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
} catch(e) {
|
|
478
|
+
throw new BedrockError('Could not update exchange.', {
|
|
479
|
+
name: 'OperationError',
|
|
480
|
+
details: {
|
|
481
|
+
public: true,
|
|
482
|
+
httpStatusCode: 500
|
|
483
|
+
},
|
|
484
|
+
cause: e
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// if no document was matched, try to get an existing exchange; if the
|
|
489
|
+
// exchange does not exist, a not found error will be automatically thrown
|
|
490
|
+
await get({workflowId, id});
|
|
491
|
+
|
|
492
|
+
/* Note: Here the exchange *does* exist, but the step or state did not
|
|
493
|
+
match which is a conflict error. */
|
|
494
|
+
|
|
495
|
+
// throw duplicate completed exchange error
|
|
496
|
+
throw new BedrockError('Could not update exchange; conflict error.', {
|
|
497
|
+
name: 'InvalidStateError',
|
|
498
|
+
details: {
|
|
499
|
+
public: true,
|
|
500
|
+
// this is a client-side conflict error
|
|
501
|
+
httpStatusCode: 409
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
411
506
|
async function _invalidateExchange({record}) {
|
|
412
507
|
try {
|
|
413
508
|
// mark exchange invalid, but do not throw any error to client; only log it
|
|
@@ -463,6 +558,48 @@ async function _markExchangeInvalid({record}) {
|
|
|
463
558
|
}
|
|
464
559
|
}
|
|
465
560
|
|
|
561
|
+
function _buildUpdate({exchange, complete}) {
|
|
562
|
+
// build update
|
|
563
|
+
const now = Date.now();
|
|
564
|
+
const update = {
|
|
565
|
+
$inc: {'exchange.sequence': 1},
|
|
566
|
+
$set: {'exchange.state': exchange.state, 'meta.updated': now},
|
|
567
|
+
$unset: {}
|
|
568
|
+
};
|
|
569
|
+
if(complete) {
|
|
570
|
+
// exchange complete, only update results
|
|
571
|
+
if(exchange.variables?.results) {
|
|
572
|
+
update.$set['exchange.variables.results'] = exchange.variables.results;
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
// exchange not complete, update all variables
|
|
576
|
+
if(exchange.variables) {
|
|
577
|
+
update.$set['exchange.variables'] = exchange.variables;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if(exchange.step !== undefined) {
|
|
581
|
+
update.$set['exchange.step'] = exchange.step;
|
|
582
|
+
}
|
|
583
|
+
// only set `ttl` if expires not previously set / has been cleared
|
|
584
|
+
if(exchange.ttl !== undefined && exchange.expires === undefined) {
|
|
585
|
+
// TTL is in seconds, convert to expires
|
|
586
|
+
const expires = new Date(now + exchange.ttl * 1000);
|
|
587
|
+
// unset and previously set `ttl`
|
|
588
|
+
update.$unset['exchange.ttl'] = true;
|
|
589
|
+
update.$set['meta.expires'] = expires;
|
|
590
|
+
update.$set['exchange.expires'] =
|
|
591
|
+
expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
592
|
+
}
|
|
593
|
+
if(exchange.lastError !== undefined) {
|
|
594
|
+
update.$set['exchange.lastError'] =
|
|
595
|
+
serializeError(stripStacktrace(exchange.lastError));
|
|
596
|
+
} else {
|
|
597
|
+
update.$unset['exchange.lastError'] = true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return update;
|
|
601
|
+
}
|
|
602
|
+
|
|
466
603
|
/**
|
|
467
604
|
* An object containing information on the query plan.
|
|
468
605
|
*
|
package/lib/helpers.js
CHANGED
|
@@ -7,6 +7,7 @@ import {decodeId, generateId} from 'bnid';
|
|
|
7
7
|
import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
|
|
8
8
|
import {httpsAgent} from '@bedrock/https-agent';
|
|
9
9
|
import jsonata from 'jsonata';
|
|
10
|
+
import {serializeError} from 'serialize-error';
|
|
10
11
|
import {serviceAgents} from '@bedrock/service-agent';
|
|
11
12
|
import {ZcapClient} from '@digitalbazaar/ezcap';
|
|
12
13
|
|
|
@@ -104,6 +105,21 @@ export function decodeLocalId({localId} = {}) {
|
|
|
104
105
|
}));
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
export function stripStacktrace(error) {
|
|
109
|
+
error = serializeError(error);
|
|
110
|
+
delete error.stack;
|
|
111
|
+
if(error.errors) {
|
|
112
|
+
error.errors = error.errors.map(stripStacktrace);
|
|
113
|
+
}
|
|
114
|
+
if(Array.isArray(error.details?.errors)) {
|
|
115
|
+
error.details.errors = error.details.errors.map(stripStacktrace);
|
|
116
|
+
}
|
|
117
|
+
if(error.cause) {
|
|
118
|
+
error.cause = stripStacktrace(error.cause);
|
|
119
|
+
}
|
|
120
|
+
return error;
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
export async function unenvelopeCredential({
|
|
108
124
|
envelopedCredential, format
|
|
109
125
|
} = {}) {
|
package/lib/http.js
CHANGED
|
@@ -113,8 +113,8 @@ export async function addRoutes({app, service} = {}) {
|
|
|
113
113
|
getConfigMiddleware,
|
|
114
114
|
asyncHandler(async (req, res) => {
|
|
115
115
|
const {config: workflow} = req.serviceObject;
|
|
116
|
-
const
|
|
117
|
-
await processExchange({req, res, workflow,
|
|
116
|
+
const exchangeRecord = await req.getExchange();
|
|
117
|
+
await processExchange({req, res, workflow, exchangeRecord});
|
|
118
118
|
}));
|
|
119
119
|
|
|
120
120
|
// create OID4* routes to be used with each individual exchange
|
package/lib/oid4/http.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function createRoutes({
|
|
|
54
54
|
ciMetadata2: `${exchangeRoute}/.well-known/openid-credential-issuer`,
|
|
55
55
|
batchCredential: `${openIdRoute}/batch_credential`,
|
|
56
56
|
credential: `${openIdRoute}/credential`,
|
|
57
|
+
credentialOffer: `${openIdRoute}/credential-offer`,
|
|
57
58
|
token: `${openIdRoute}/token`,
|
|
58
59
|
jwks: `${openIdRoute}/jwks`,
|
|
59
60
|
// OID4VP routes
|
|
@@ -217,6 +218,18 @@ export async function createRoutes({
|
|
|
217
218
|
});
|
|
218
219
|
}));
|
|
219
220
|
|
|
221
|
+
// a credential delivery server endpoint
|
|
222
|
+
// serves the credential offer for all possible credentials in the exchange
|
|
223
|
+
app.get(
|
|
224
|
+
routes.credentialOffer,
|
|
225
|
+
cors(),
|
|
226
|
+
getConfigMiddleware,
|
|
227
|
+
getExchange,
|
|
228
|
+
asyncHandler(async (req, res) => {
|
|
229
|
+
const offer = await oid4vci.getCredentialOffer({req});
|
|
230
|
+
res.json(offer);
|
|
231
|
+
}));
|
|
232
|
+
|
|
220
233
|
// a batch credential delivery server endpoint
|
|
221
234
|
// receives N credential requests and returns N VCs
|
|
222
235
|
app.options(routes.batchCredential, cors());
|