Skip to content

Commit e0667c3

Browse files
committed
Ensure exchanges are updated w/step results.
1 parent fb74588 commit e0667c3

10 files changed

+200
-44
lines changed

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
### Added
66
- Add GET endpoint for getting exchange information from any existing
7-
exchange, particularly useful for obtaining its current state.
7+
exchange, particularly useful for obtaining its current state and
8+
any user-submitted data.
9+
10+
### Fixed
11+
- Ensure exchanges are updated when steps are completed.
812

913
## 3.0.2 - 2023-06-26
1014

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# bedrock-module-template-http
1+
# bedrock-vc-delivery

lib/exchanges.js

+122-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*!
2-
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
2+
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
33
*/
44
import * as bedrock from '@bedrock/core';
55
import * as database from '@bedrock/mongodb';
@@ -156,21 +156,123 @@ export async function get({exchangerId, id, explain = false} = {}) {
156156
return record;
157157
}
158158

159+
/**
160+
* Updates a pending exchange with new variables, step, and TTL information.
161+
*
162+
* @param {object} options - The options to use.
163+
* @param {string} options.exchangerId - The ID of the exchanger the exchange
164+
* is associated with.
165+
* @param {object} options.exchange - The exchange to update.
166+
* @param {string} [options.expectedStep] - The expected current step in the
167+
* exchange.
168+
* @param {boolean} [options.explain=false] - An optional explain boolean.
169+
*
170+
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
171+
* success or an ExplainObject if `explain=true`.
172+
*/
173+
export async function update({
174+
exchangerId, exchange, expectedStep, explain = false
175+
} = {}) {
176+
assert.string(exchangerId, 'exchangerId');
177+
assert.object(exchange, 'exchange');
178+
assert.optionalString(expectedStep, expectedStep);
179+
const {id} = exchange;
180+
181+
// build update
182+
const now = Date.now();
183+
const update = {
184+
$set: {'meta.updated': now}
185+
};
186+
// update exchange `variables.results[step]`, `step`, and `ttl`
187+
if(exchange.variables?.results) {
188+
update.$set['exchange.variables.results'] = exchange.variables.results;
189+
}
190+
if(exchange.step !== undefined) {
191+
update.$set['exchange.step'] = exchange.step;
192+
}
193+
if(exchange.ttl !== undefined) {
194+
update.$set['exchange.ttl'] = exchange.ttl;
195+
}
196+
197+
const {localId: localExchangerId} = parseLocalId({id: exchangerId});
198+
199+
const collection = database.collections[COLLECTION_NAME];
200+
const query = {
201+
localExchangerId,
202+
'exchange.id': id,
203+
// previous state must be `pending` in order to update it
204+
'exchange.state': 'pending'
205+
};
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+
213+
if(explain) {
214+
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
215+
// cursor which allows the use of the explain function.
216+
const cursor = await collection.find(query).limit(1);
217+
return cursor.explain('executionStats');
218+
}
219+
220+
try {
221+
const result = await collection.updateOne(query, update);
222+
if(result.result.n > 0) {
223+
// document modified: success
224+
return true;
225+
}
226+
} catch(e) {
227+
throw new BedrockError('Could not update exchange.', {
228+
name: 'OperationError',
229+
details: {
230+
public: true,
231+
httpStatusCode: 500
232+
},
233+
cause: e
234+
});
235+
}
236+
237+
// if no document was matched, try to get an existing exchange; if the
238+
// exchange does not exist, a not found error will be automatically thrown
239+
await get({exchangerId, id});
240+
241+
/* Note: Here the exchange *does* exist, but the step or state did not
242+
match which is a conflict error. */
243+
244+
// throw duplicate completed exchange error
245+
throw new BedrockError('Could not update exchange; conflict error.', {
246+
name: 'InvalidStateError',
247+
details: {
248+
public: true,
249+
// this is a client-side conflict error
250+
httpStatusCode: 409
251+
}
252+
});
253+
}
254+
159255
/**
160256
* Marks an exchange as complete.
161257
*
162258
* @param {object} options - The options to use.
163259
* @param {string} options.exchangerId - The ID of the exchanger the exchange
164260
* is associated with.
165-
* @param {object} options.id - The ID of the exchange to mark as complete.
261+
* @param {object} options.exchange - The exchange to mark as complete.
262+
* @param {string} [options.expectedStep] - The expected current step in the
263+
* exchange.
166264
* @param {boolean} [options.explain=false] - An optional explain boolean.
167265
*
168266
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
169267
* success or an ExplainObject if `explain=true`.
170268
*/
171-
export async function complete({exchangerId, id, explain = false} = {}) {
269+
export async function complete({
270+
exchangerId, exchange, expectedStep, explain = false
271+
} = {}) {
172272
assert.string(exchangerId, 'exchangerId');
173-
assert.string(id, 'id');
273+
assert.object(exchange, 'exchange');
274+
assert.optionalString(expectedStep, expectedStep);
275+
const {id} = exchange;
174276

175277
// build update
176278
const now = Date.now();
@@ -180,6 +282,16 @@ export async function complete({exchangerId, id, explain = false} = {}) {
180282
'meta.updated': now
181283
}
182284
};
285+
// update exchange `variables.results[step]`, `step`, and `ttl`
286+
if(exchange.variables?.results) {
287+
update.$set['exchange.variables.results'] = exchange.variables.results;
288+
}
289+
if(exchange.step !== undefined) {
290+
update.$set['exchange.step'] = exchange.step;
291+
}
292+
if(exchange.ttl !== undefined) {
293+
update.$set['exchange.ttl'] = exchange.ttl;
294+
}
183295

184296
const {localId: localExchangerId} = parseLocalId({id: exchangerId});
185297

@@ -190,6 +302,12 @@ export async function complete({exchangerId, id, explain = false} = {}) {
190302
// previous state must be `pending` in order to change to `complete`
191303
'exchange.state': 'pending'
192304
};
305+
// current step must match previous step
306+
if(expectedStep === undefined) {
307+
query['exchange.step'] = null;
308+
} else {
309+
query['exchange.step'] = expectedStep;
310+
}
193311

194312
if(explain) {
195313
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a

lib/http.js

+50-25
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,28 @@ export async function addRoutes({app, service} = {}) {
159159
const {config: exchanger} = req.serviceObject;
160160
const {exchange} = await req.exchange;
161161

162-
// process exchange step if present
163-
if(exchange.step) {
164-
const step = exchanger.steps[exchange.step];
162+
// get any `verifiablePresentation` from the body...
163+
let receivedPresentation = req?.body?.verifiablePresentation;
165164

166-
// handle VPR; if step requires it, then `verifiablePresentation` must
165+
// process exchange step(s)
166+
let currentStep = exchange.step;
167+
while(true) {
168+
// no step present, break out to complete exchange
169+
if(!currentStep) {
170+
break;
171+
}
172+
173+
// get current step details
174+
const step = exchanger.steps[currentStep];
175+
176+
// handle VPR: if step requires it, then `verifiablePresentation` must
167177
// be in the request
168178
if(step.verifiablePresentationRequest) {
169179
const {createChallenge} = step;
170180
const isInitialStep = exchange.step === exchanger.initialStep;
171181

172-
// if `verifiablePresentation` is not in the body...
173-
const presentation = req?.body?.verifiablePresentation;
174-
if(!presentation) {
182+
// if no presentation was received in the body...
183+
if(!receivedPresentation) {
175184
const verifiablePresentationRequest = klona(
176185
step.verifiablePresentationRequest);
177186
if(createChallenge) {
@@ -189,38 +198,54 @@ export async function addRoutes({app, service} = {}) {
189198
}
190199
verifiablePresentationRequest.challenge = challenge;
191200
}
192-
// send VPR
201+
// send VPR and return
193202
res.json({verifiablePresentationRequest});
194203
return;
195204
}
196205

197-
// verify the VP
206+
// verify the received VP
198207
const expectedChallenge = isInitialStep ? exchange.id : undefined;
199-
const {verificationMethod} = await verify(
200-
{exchanger, presentation, expectedChallenge});
208+
const {verificationMethod} = await verify({
209+
exchanger, presentation: receivedPresentation, expectedChallenge
210+
});
201211

202-
// FIXME: ensure VP satisfies step VPR; implement templates (jsonata)
203-
// to convert VPR responses to more exchange variables
212+
// FIXME: ensure VP satisfies step VPR
204213

205-
// store VP in variables
206-
exchange.variables[exchange.step] = {
214+
// store VP results in variables associated with current step
215+
if(!exchange.variables.results) {
216+
exchange.variables.results = {};
217+
}
218+
exchange.variables.results[currentStep] = {
219+
// common use case of DID Authentication; provide `did` for ease
220+
// of use in templates and consistency with OID4VCI which only
221+
// receives `did` not verification method nor VP
207222
did: verificationMethod.controller,
208-
verifiablePresentation: presentation
223+
verificationMethod,
224+
verifiablePresentation: receivedPresentation
209225
};
210226

211-
// FIXME: update the exchange to go to the next step if there is one
212-
// if(step.nextStep) {
213-
// exchange.step = step.nextStep;
214-
// // FIXME: break exchange step processor into its own local API;
215-
// // ensure VPR has been met, store VP in variables, and
216-
// // loop to send next VPR
217-
// return;
218-
// }
227+
// clear received presentation as it has been processed
228+
receivedPresentation = null;
229+
230+
// if there is no next step, break out to complete exchange
231+
if(!step.nextStep) {
232+
break;
233+
}
234+
235+
// update the exchange to go to the next step, then loop to send
236+
// next VPR
237+
exchange.step = step.nextStep;
238+
await exchanges.update({
239+
exchangerId: exchanger.id, exchange, expectedStep: currentStep
240+
});
241+
currentStep = exchange.step;
219242
}
220243
}
221244

222245
// mark exchange complete
223-
await exchanges.complete({exchangerId: exchanger.id, id: exchange.id});
246+
await exchanges.complete({
247+
exchangerId: exchanger.id, exchange, expectedStep: currentStep
248+
});
224249

225250
// FIXME: decide what the best recovery path is if delivery fails (but no
226251
// replay attack detected) after exchange has been marked complete

lib/index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ async function validateConfigFn({config} = {}) {
100100
}
101101

102102
// if `steps` are specified, then `initialStep` MUST be included
103-
const {steps = [], initialStep} = config;
104-
if(steps.length > 0 && initialStep === undefined) {
103+
const {steps, initialStep} = config;
104+
if(steps && initialStep === undefined) {
105105
throw new BedrockError(
106106
'"initialStep" is required when "steps" are provided.', {
107107
name: 'DataError',

lib/openId.js

+14-4
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,8 @@ async function _processCredentialRequests({req, res, isBatchRequest}) {
394394
{credentialRequests, expectedCredentialRequests});
395395

396396
// process exchange step if present
397-
if(exchange.step) {
397+
const currentStep = exchange.step;
398+
if(currentStep) {
398399
const step = exchanger.steps[exchange.step];
399400

400401
// handle JWT DID Proof request; if step requires it, then `proof` must
@@ -418,13 +419,22 @@ async function _processCredentialRequests({req, res, isBatchRequest}) {
418419
// FIXME: improve error
419420
throw new Error('every DID must be the same');
420421
}
421-
// add `did` to exchange variables
422-
exchange.variables[exchange.step] = {did};
422+
// store did results in variables associated with current step
423+
if(!exchange.variables.results) {
424+
exchange.variables.results = {};
425+
}
426+
exchange.variables.results[currentStep] = {
427+
// common use case of DID Authentication; provide `did` for ease
428+
// of use in templates
429+
did
430+
};
423431
}
424432
}
425433

426434
// mark exchange complete
427-
await exchanges.complete({exchangerId: exchanger.id, id: exchange.id});
435+
await exchanges.complete({
436+
exchangerId: exchanger.id, exchange, expectedStep: currentStep
437+
});
428438

429439
// FIXME: decide what the best recovery path is if delivery fails (but no
430440
// replay attack detected) after exchange has been marked complete

schemas/bedrock-vc-exchanger.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,12 @@ const step = {
172172
}
173173
}
174174
},
175+
nextStep: {
176+
type: 'string'
177+
}
175178
// FIXME: add jsonata template to convert VPR or
176179
// `jwtDidProofRequest` to more variables to be
177180
// used when issuing VCs
178-
// FIXME: `nextStep` feature not yet implemented
179-
// nextStep: {
180-
// type: 'string'
181-
// }
182181
}
183182
};
184183

test/mocha/20-vcapi.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*!
2-
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
2+
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
33
*/
44
import * as helpers from './helpers.js';
55
import {agent} from '@bedrock/https-agent';

test/mocha/21-vcapi-did-authn.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*!
2-
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
2+
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
33
*/
44
import * as helpers from './helpers.js';
55
import {agent} from '@bedrock/https-agent';

test/mocha/mock.data.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ mockData.didAuthnCredentialTemplate = `
112112
],
113113
"issuanceDate": issuanceDate,
114114
"credentialSubject": {
115-
"id": didAuthn.did,
115+
"id": results.didAuthn.did,
116116
"degree": {
117117
"type": "BachelorDegree",
118118
"name": "Bachelor of Science and Arts"

0 commit comments

Comments
 (0)