@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 +44 -33
- package/lib/helpers.js +12 -1
- package/lib/http.js +15 -13
- package/lib/issue.js +1 -1
- package/lib/openId.js +263 -17
- package/package.json +2 -1
- package/schemas/bedrock-vc-exchanger.js +97 -2
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
|
|
187
|
-
if(exchange.variables
|
|
188
|
-
update.$set['exchange.variables
|
|
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
|
|
341
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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 {
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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.
|
|
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
|
+
}
|