@bedrock/vc-delivery 5.0.1 → 5.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/openId.js DELETED
@@ -1,1057 +0,0 @@
1
- /*!
2
- * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
- */
4
- import * as bedrock from '@bedrock/core';
5
- import * as exchanges from './exchanges.js';
6
- import {
7
- compile, createValidateMiddleware as validate
8
- } from '@bedrock/validation';
9
- import {
10
- evaluateTemplate, getWorkflowIssuerInstances, unenvelopePresentation
11
- } from './helpers.js';
12
- import {importJWK, SignJWT} from 'jose';
13
- import {
14
- openIdAuthorizationResponseBody,
15
- openIdBatchCredentialBody,
16
- openIdCredentialBody,
17
- openIdTokenBody,
18
- presentationSubmission as presentationSubmissionSchema,
19
- verifiablePresentation as verifiablePresentationSchema
20
- } from '../schemas/bedrock-vc-workflow.js';
21
- import {verify, verifyDidProofJwt} from './verify.js';
22
- import {asyncHandler} from '@bedrock/express';
23
- import bodyParser from 'body-parser';
24
- import {checkAccessToken} from '@bedrock/oauth2-verifier';
25
- import cors from 'cors';
26
- import {issue} from './issue.js';
27
- import {klona} from 'klona';
28
- import {oid4vp} from '@digitalbazaar/oid4-client';
29
- import {timingSafeEqual} from 'node:crypto';
30
- import {UnsecuredJWT} from 'jose';
31
-
32
- const {util: {BedrockError}} = bedrock;
33
-
34
- /* NOTE: Parts of the OID4VCI design imply tight integration between the
35
- authorization server and the credential issuance / delivery server. This
36
- file provides the routes for both and treats them as integrated; supporting
37
- the OID4VCI pre-authz code flow only as a result. However, we also try to
38
- avoid tight-coupling where possible to enable the non-pre-authz code flow
39
- that would use, somehow, a separate authorization server.
40
-
41
- One tight coupling we try to avoid involves the option where the authorization
42
- server generates the challenge nonce to be signed in a DID proof, but the
43
- credential delivery server is the system responsible for checking and tracking
44
- this challenge. The Credential Delivery server cannot know the challenge is
45
- authentic without breaking some abstraction around how the Authorization
46
- Server is implemented behind its API. Here we do not implement this option,
47
- instead, if a challenge is required, the credential delivery server will send
48
- an error with the challenge nonce if one was not provided in the payload to the
49
- credential endpoint. This error follows the OID4VCI spec and avoids this
50
- particular tight coupling.
51
-
52
- Other tight couplings cannot be avoided at this time -- such as the fact that
53
- the credential endpoint is specified in the authorization server's metadata;
54
- this creates challenges for SaaS based solutions and for issuers that want to
55
- use multiple different Issuance / Delivery server backends. We solve these
56
- challenges by using the "pre-authorized code" flows and effectively
57
- instantiating a new authorization server instance per VC exchange. */
58
-
59
- const PRE_AUTH_GRANT_TYPE =
60
- 'urn:ietf:params:oauth:grant-type:pre-authorized_code';
61
-
62
- const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
63
-
64
- // creates OID4VCI Authorization Server + Credential Delivery Server
65
- // endpoints for each individual exchange
66
- export async function createRoutes({
67
- app, exchangeRoute, getConfigMiddleware, getExchange
68
- } = {}) {
69
- const openIdRoute = `${exchangeRoute}/openid`;
70
- const routes = {
71
- // OID4VCI routes
72
- asMetadata: `/.well-known/oauth-authorization-server${exchangeRoute}`,
73
- asMetadataDraftBug:
74
- `${exchangeRoute}/.well-known/oauth-authorization-server`,
75
- ciMetadata: `/.well-known/openid-credential-issuer${exchangeRoute}`,
76
- ciMetadataDraftBug:
77
- `${exchangeRoute}/.well-known/openid-credential-issuer`,
78
- batchCredential: `${openIdRoute}/batch_credential`,
79
- credential: `${openIdRoute}/credential`,
80
- token: `${openIdRoute}/token`,
81
- jwks: `${openIdRoute}/jwks`,
82
- // OID4VP routes
83
- authorizationRequest: `${openIdRoute}/client/authorization/request`,
84
- authorizationResponse: `${openIdRoute}/client/authorization/response`
85
- };
86
-
87
- // urlencoded body parser (extended=true for rich JSON-like representation)
88
- const urlencoded = bodyParser.urlencoded({extended: true});
89
-
90
- // create validators for x-www-form-urlencoded parsed data
91
- const validatePresentation = compile(
92
- {schema: verifiablePresentationSchema()});
93
- const validatePresentationSubmission = compile(
94
- {schema: presentationSubmissionSchema});
95
-
96
- // an authorization server meta data endpoint
97
- // serves `.well-known` oauth2 AS config for each exchange; each config is
98
- // based on the workflow used to create the exchange
99
- app.get(
100
- routes.asMetadata,
101
- cors(),
102
- getConfigMiddleware,
103
- asyncHandler(async (req, res) => {
104
- // generate well-known oauth2 issuer config
105
- const {config: workflow} = req.serviceObject;
106
- const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
107
- // note that technically, we should not need to serve any credential
108
- // issuer metadata, but we do for backwards compatibility purposes as
109
- // previous versions of OID4VCI required it
110
- const oauth2Config = {
111
- issuer: exchangeId,
112
- jwks_uri: `${exchangeId}/openid/jwks`,
113
- token_endpoint: `${exchangeId}/openid/token`,
114
- credential_endpoint: `${exchangeId}/openid/credential`,
115
- batch_credential_endpoint: `${exchangeId}/openid/batch_credential`,
116
- 'pre-authorized_grant_anonymous_access_supported': true
117
- };
118
- res.json(oauth2Config);
119
- }));
120
-
121
- // a credential issuer meta data endpoint
122
- // serves `.well-known` oauth2 AS / CI config for each exchange; each config
123
- // is based on the workflow used to create the exchange
124
- app.get(
125
- routes.ciMetadata,
126
- cors(),
127
- getConfigMiddleware,
128
- asyncHandler(async (req, res) => {
129
- // generate well-known oauth2 issuer config
130
- const {config: workflow} = req.serviceObject;
131
- const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
132
- const oauth2Config = {
133
- issuer: exchangeId,
134
- jwks_uri: `${exchangeId}/openid/jwks`,
135
- token_endpoint: `${exchangeId}/openid/token`,
136
- credential_endpoint: `${exchangeId}/openid/credential`,
137
- batch_credential_endpoint: `${exchangeId}/openid/batch_credential`,
138
- 'pre-authorized_grant_anonymous_access_supported': true
139
- };
140
- res.json(oauth2Config);
141
- }));
142
-
143
- // an authorization server endpoint
144
- // serves JWKs associated with each exchange; JWKs are stored with the
145
- // workflow used to create the exchange
146
- app.get(
147
- routes.jwks,
148
- cors(),
149
- getExchange,
150
- asyncHandler(async (req, res) => {
151
- const {exchange} = await req.getExchange();
152
- if(!exchange.openId) {
153
- _throwUnsupportedProtocol();
154
- }
155
- // serve exchange's public key
156
- res.json({keys: [exchange.openId.oauth2.keyPair.publicKeyJwk]});
157
- }));
158
-
159
- // an authorization server endpoint
160
- // handles pre-authorization code exchange for access token; only supports
161
- // pre-authorization code grant type
162
- app.options(routes.token, cors());
163
- app.post(
164
- routes.token,
165
- cors(),
166
- urlencoded,
167
- validate({bodySchema: openIdTokenBody}),
168
- getConfigMiddleware,
169
- getExchange,
170
- asyncHandler(async (req, res) => {
171
- const exchangeRecord = await req.getExchange();
172
- const {exchange} = exchangeRecord;
173
- if(!exchange.openId) {
174
- _throwUnsupportedProtocol();
175
- }
176
-
177
- /* Examples of types of token requests:
178
- pre-authz code:
179
- grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
180
- &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
181
- &user_pin=493536
182
-
183
- authz code:
184
- grant_type=authorization_code
185
- &code=SplxlOBeZQQYbYS6WxSbIA
186
- &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
187
- &redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb */
188
-
189
- const {
190
- grant_type: grantType,
191
- 'pre-authorized_code': preAuthorizedCode,
192
- //user_pin: userPin
193
- } = req.body;
194
-
195
- if(grantType !== PRE_AUTH_GRANT_TYPE) {
196
- // unsupported grant type
197
- // FIXME: throw proper oauth2 formatted error
198
- throw new Error('Unsupported grant type.');
199
- }
200
-
201
- // validate grant type
202
- const {openId: {preAuthorizedCode: expectedCode}} = exchange;
203
- if(expectedCode) {
204
- // ensure expected pre-authz code matches
205
- if(!timingSafeEqual(
206
- Buffer.from(expectedCode, 'utf8'),
207
- Buffer.from(preAuthorizedCode, 'utf8'))) {
208
- // FIXME: throw proper oauth2 formatted error
209
- throw new Error('invalid pre-authorized-code or user pin');
210
- }
211
- }
212
-
213
- // create access token
214
- const {config: workflow} = req.serviceObject;
215
- const {accessToken, ttl} = await _createExchangeAccessToken(
216
- {workflow, exchangeRecord});
217
-
218
- // send response
219
- const body = {
220
- access_token: accessToken,
221
- token_type: 'bearer',
222
- expires_in: ttl
223
- };
224
- res.json(body);
225
- }));
226
-
227
- // a credential delivery server endpoint
228
- // receives a credential request and returns VCs
229
- app.options(routes.credential, cors());
230
- app.post(
231
- routes.credential,
232
- cors(),
233
- validate({bodySchema: openIdCredentialBody}),
234
- getConfigMiddleware,
235
- getExchange,
236
- asyncHandler(async (req, res) => {
237
- /* Clients must POST, e.g.:
238
- POST /credential HTTP/1.1
239
- Host: server.example.com
240
- Content-Type: application/json
241
- Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
242
-
243
- {
244
- "format": "ldp_vc",
245
- "credential_definition": {
246
- "@context": [
247
- "https://www.w3.org/2018/credentials/v1",
248
- "https://www.w3.org/2018/credentials/examples/v1"
249
- ],
250
- "type": [
251
- "VerifiableCredential",
252
- "UniversityDegreeCredential"
253
- ]
254
- },
255
- "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
256
- "proof": {
257
- "proof_type": "jwt",
258
- "jwt": "eyJra...nOzM"
259
- }
260
- }
261
- */
262
-
263
- const result = await _processCredentialRequests(
264
- {req, res, isBatchRequest: false});
265
- if(!result) {
266
- // DID proof request response sent
267
- return;
268
- }
269
-
270
- /* Note: The `/credential` route only supports sending a single VC;
271
- assume here that this workflow is configured for a single VC and an
272
- error code would have been sent to the client to use the batch
273
- endpoint if there was more than one VC to deliver. */
274
- const {response, format} = result;
275
- const {verifiablePresentation: {verifiableCredential: [vc]}} = response;
276
-
277
- // parse any enveloped VC
278
- let credential;
279
- if(vc.type === 'EnvelopedVerifiableCredential' &&
280
- vc.id?.startsWith('data:application/jwt,')) {
281
- credential = vc.id.slice('data:application/jwt,'.length);
282
- } else {
283
- credential = vc;
284
- }
285
-
286
- // send OID4VCI response
287
- res.json({
288
- // FIXME: this doesn't seem to be in the spec anymore (draft 14+)...
289
- format,
290
- credential
291
- });
292
- }));
293
-
294
- // a batch credential delivery server endpoint
295
- // receives N credential requests and returns N VCs
296
- app.options(routes.batchCredential, cors());
297
- app.post(
298
- routes.batchCredential,
299
- cors(),
300
- validate({bodySchema: openIdBatchCredentialBody}),
301
- getConfigMiddleware,
302
- getExchange,
303
- asyncHandler(async (req, res) => {
304
- /* Clients must POST, e.g.:
305
- POST /batch_credential HTTP/1.1
306
- Host: server.example.com
307
- Content-Type: application/json
308
- Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
309
-
310
- {
311
- credential_requests: [{
312
- "format": "ldp_vc",
313
- "credential_definition": {
314
- "@context": [
315
- "https://www.w3.org/2018/credentials/v1",
316
- "https://www.w3.org/2018/credentials/examples/v1"
317
- ],
318
- "type": [
319
- "VerifiableCredential",
320
- "UniversityDegreeCredential"
321
- ]
322
- },
323
- "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
324
- "proof": {
325
- "proof_type": "jwt",
326
- "jwt": "eyJra...nOzM"
327
- }
328
- }]
329
- }
330
- */
331
- const result = await _processCredentialRequests(
332
- {req, res, isBatchRequest: true});
333
- if(!result) {
334
- // DID proof request response sent
335
- return;
336
- }
337
-
338
- // send VCs
339
- const {response: {verifiablePresentation}, format} = result;
340
- // FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)...
341
- const responses = verifiablePresentation.verifiableCredential.map(vc => {
342
- // parse any enveloped VC
343
- let credential;
344
- if(vc.type === 'EnvelopedVerifiableCredential' &&
345
- vc.id?.startsWith('data:application/jwt,')) {
346
- credential = vc.id.slice('data:application/jwt,'.length);
347
- } else {
348
- credential = vc;
349
- }
350
- return {format, credential};
351
- });
352
- res.json({credential_responses: responses});
353
- }));
354
-
355
- // an OID4VP verifier endpoint
356
- // serves the authorization request, including presentation definition
357
- // associated with the current step in the exchange
358
- app.get(
359
- routes.authorizationRequest,
360
- cors(),
361
- getConfigMiddleware,
362
- getExchange,
363
- asyncHandler(async (req, res) => {
364
- const {authorizationRequest} = await _getAuthorizationRequest({req});
365
- // construct and send authz request as unsecured JWT
366
- const jwt = new UnsecuredJWT(authorizationRequest).encode();
367
- res.set('content-type', 'application/oauth-authz-req+jwt');
368
- res.send(jwt);
369
- }));
370
-
371
- // an OID4VP verifier endpoint
372
- // receives an authorization response with vp_token
373
- app.options(routes.authorizationResponse, cors());
374
- app.post(
375
- routes.authorizationResponse,
376
- cors(),
377
- urlencoded,
378
- validate({bodySchema: openIdAuthorizationResponseBody()}),
379
- getConfigMiddleware,
380
- getExchange,
381
- asyncHandler(async (req, res) => {
382
- // get JSON `vp_token` and `presentation_submission`
383
- const {vp_token, presentation_submission} = req.body;
384
-
385
- // JSON parse and validate `vp_token` and `presentation_submission`
386
- let presentation = _jsonParse(vp_token, 'vp_token');
387
- const presentationSubmission = _jsonParse(
388
- presentation_submission, 'presentation_submission');
389
- _validate(validatePresentationSubmission, presentationSubmission);
390
- let envelope;
391
- if(typeof presentation === 'string') {
392
- // handle enveloped presentation
393
- const {
394
- envelope: raw, presentation: contents, format
395
- } = await unenvelopePresentation({
396
- envelopedPresentation: presentation,
397
- // FIXME: check presentationSubmission for VP format
398
- format: 'jwt_vc_json-ld'
399
- });
400
- _validate(validatePresentation, contents);
401
- presentation = {
402
- '@context': VC_CONTEXT_2,
403
- id: `data:${format},${raw}`,
404
- type: 'EnvelopedVerifiablePresentation'
405
- };
406
- envelope = {raw, contents, format};
407
- } else {
408
- _validate(validatePresentation, presentation);
409
- }
410
- const result = await _processAuthorizationResponse({
411
- req, presentation, envelope, presentationSubmission
412
- });
413
- res.json(result);
414
- }));
415
-
416
- /* Note: The following routes are served only because of an OID4VCI draft bug
417
- that tells clients to generate `/.well-known` paths in an erroneous way and
418
- some implementers have complied. */
419
-
420
- // an authorization server meta data endpoint
421
- // serves `.well-known` oauth2 AS config for each exchange; each config is
422
- // based on the workflow used to create the exchange
423
- app.get(
424
- routes.asMetadataDraftBug,
425
- cors(),
426
- getConfigMiddleware,
427
- asyncHandler(async (req, res) => {
428
- // generate well-known oauth2 issuer config
429
- const {config: workflow} = req.serviceObject;
430
- const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
431
- // note that technically, we should not need to serve any credential
432
- // issuer metadata, but we do for backwards compatibility purposes as
433
- // previous versions of OID4VCI required it
434
- const oauth2Config = {
435
- issuer: exchangeId,
436
- jwks_uri: `${exchangeId}/openid/jwks`,
437
- token_endpoint: `${exchangeId}/openid/token`,
438
- credential_endpoint: `${exchangeId}/openid/credential`,
439
- batch_credential_endpoint: `${exchangeId}/openid/batch_credential`
440
- };
441
- res.json(oauth2Config);
442
- }));
443
-
444
- // a credential issuer meta data endpoint
445
- // serves `.well-known` oauth2 AS / CI config for each exchange; each config
446
- // is based on the workflow used to create the exchange
447
- app.get(
448
- routes.ciMetadataDraftBug,
449
- cors(),
450
- getConfigMiddleware,
451
- asyncHandler(async (req, res) => {
452
- // generate well-known oauth2 issuer config
453
- const {config: workflow} = req.serviceObject;
454
- const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
455
- const oauth2Config = {
456
- issuer: exchangeId,
457
- jwks_uri: `${exchangeId}/openid/jwks`,
458
- token_endpoint: `${exchangeId}/openid/token`,
459
- credential_endpoint: `${exchangeId}/openid/credential`,
460
- batch_credential_endpoint: `${exchangeId}/openid/batch_credential`
461
- };
462
- res.json(oauth2Config);
463
- }));
464
- }
465
-
466
- async function _createExchangeAccessToken({workflow, exchangeRecord}) {
467
- // FIXME: set `exp` to max of 15 minutes / configured max minutes
468
- const expires = exchangeRecord.meta.expires;
469
- const exp = Math.floor(expires.getTime() / 1000);
470
-
471
- // create access token
472
- const {exchange} = exchangeRecord;
473
- const {openId: {oauth2: {keyPair: {privateKeyJwk}}}} = exchange;
474
- const exchangeId = `${workflow.id}/exchanges/${exchange.id}`;
475
- const {accessToken, ttl} = await _createOAuth2AccessToken({
476
- privateKeyJwk, audience: exchangeId, action: 'write', target: exchangeId,
477
- exp, iss: exchangeId
478
- });
479
- return {accessToken, ttl};
480
- }
481
-
482
- async function _createOAuth2AccessToken({
483
- privateKeyJwk, audience, action, target, exp, iss, nbf, typ = 'at+jwt'
484
- }) {
485
- const alg = _getAlgFromPrivateKey({privateKeyJwk});
486
- const scope = `${action}:${target}`;
487
- const builder = new SignJWT({scope})
488
- .setProtectedHeader({alg, typ})
489
- .setIssuer(iss)
490
- .setAudience(audience);
491
- let ttl;
492
- if(exp !== undefined) {
493
- builder.setExpirationTime(exp);
494
- ttl = Math.max(0, exp - Math.floor(Date.now() / 1000));
495
- } else {
496
- // default to 15 minute expiration time
497
- builder.setExpirationTime('15m');
498
- ttl = Math.floor(Date.now() / 1000) + 15 * 60;
499
- }
500
- if(nbf !== undefined) {
501
- builder.setNotBefore(nbf);
502
- }
503
- const key = await importJWK({...privateKeyJwk, alg});
504
- const accessToken = await builder.sign(key);
505
- return {accessToken, ttl};
506
- }
507
-
508
- async function _checkAuthz({req, workflow, exchange}) {
509
- // optional oauth2 options
510
- const {oauth2} = exchange.openId;
511
- const {maxClockSkew} = oauth2;
512
-
513
- // audience is always the `exchangeId` and cannot be configured; this
514
- // prevents attacks where access tokens could otherwise be generated
515
- // if the AS keys were compromised; the `exchangeId` must also be known
516
- const exchangeId = `${workflow.id}/exchanges/${req.params.exchangeId}`;
517
- const audience = exchangeId;
518
-
519
- // `issuerConfigUrl` is always based off of the `exchangeId` as well
520
- const parsedIssuer = new URL(exchangeId);
521
- const issuerConfigUrl =
522
- `${parsedIssuer.origin}/.well-known/oauth-authorization-server` +
523
- parsedIssuer.pathname;
524
-
525
- // FIXME: `allowedAlgorithms` should be computed from `oauth2.keyPair`
526
- // const allowedAlgorithms =
527
-
528
- // ensure access token is valid
529
- await checkAccessToken({req, issuerConfigUrl, maxClockSkew, audience});
530
- }
531
-
532
- function _getAlgFromPrivateKey({privateKeyJwk}) {
533
- if(privateKeyJwk.alg) {
534
- return privateKeyJwk.alg;
535
- }
536
- if(privateKeyJwk.kty === 'EC' && privateKeyJwk.crv) {
537
- if(privateKeyJwk.crv.startsWith('P-')) {
538
- return `ES${privateKeyJwk.crv.slice(2)}`;
539
- }
540
- if(privateKeyJwk.crv === 'secp256k1') {
541
- return 'ES256K';
542
- }
543
- }
544
- if(privateKeyJwk.kty === 'OKP' && privateKeyJwk.crv?.startsWith('Ed')) {
545
- return 'EdDSA';
546
- }
547
- if(privateKeyJwk.kty === 'RSA') {
548
- return 'PS256';
549
- }
550
- return 'invalid';
551
- }
552
-
553
- async function _processCredentialRequests({req, res, isBatchRequest}) {
554
- const {config: workflow} = req.serviceObject;
555
- const exchangeRecord = await req.getExchange();
556
- const {exchange} = exchangeRecord;
557
- if(!exchange.openId) {
558
- _throwUnsupportedProtocol();
559
- }
560
-
561
- // ensure oauth2 access token is valid
562
- await _checkAuthz({req, workflow, exchange});
563
-
564
- // validate body against expected credential requests
565
- const {openId: {expectedCredentialRequests}} = exchange;
566
- let credentialRequests;
567
- if(isBatchRequest) {
568
- ({credential_requests: credentialRequests} = req.body);
569
- } else {
570
- if(expectedCredentialRequests.length > 1) {
571
- // clients interacting with exchanges with more than one VC to be
572
- // delivered must use the "batch credential" endpoint
573
- // FIXME: improve error
574
- throw new Error('batch_credential_endpoint must be used');
575
- }
576
- credentialRequests = [req.body];
577
- }
578
-
579
- // before asserting, normalize credential requests to use `type` instead of
580
- // `types`; this is to allow for OID4VCI draft implementers that followed
581
- // the non-normative examples
582
- _normalizeCredentialDefinitionTypes({credentialRequests});
583
- const {format} = _assertCredentialRequests({
584
- workflow, credentialRequests, expectedCredentialRequests
585
- });
586
-
587
- // process exchange step if present
588
- const currentStep = exchange.step;
589
- if(currentStep) {
590
- let step = workflow.steps[exchange.step];
591
- if(step.stepTemplate) {
592
- // generate step from the template; assume the template type is
593
- // `jsonata` per the JSON schema
594
- step = await evaluateTemplate(
595
- {workflow, exchange, typedTemplate: step.stepTemplate});
596
- if(Object.keys(step).length === 0) {
597
- throw new BedrockError('Could not create exchange step.', {
598
- name: 'DataError',
599
- details: {httpStatusCode: 500, public: true}
600
- });
601
- }
602
- }
603
-
604
- // do late workflow configuration validation
605
- const {jwtDidProofRequest, openId} = step;
606
- // use of `jwtDidProofRequest` and `openId` together is prohibited
607
- if(jwtDidProofRequest && openId) {
608
- throw new BedrockError(
609
- 'Invalid workflow configuration; only one of ' +
610
- '"jwtDidProofRequest" and "openId" is permitted in a step.', {
611
- name: 'DataError',
612
- details: {httpStatusCode: 500, public: true}
613
- });
614
- }
615
-
616
- // check to see if step supports OID4VP
617
- if(step.openId) {
618
- // if there is no `presentationSubmission`, request one
619
- const {results} = exchange.variables;
620
- if(!results?.[exchange.step]?.openId?.presentationSubmission) {
621
- // FIXME: optimize away double step-template processing that currently
622
- // occurs when calling `_getAuthorizationRequest`
623
- const {authorizationRequest} = await _getAuthorizationRequest({req});
624
- return _requestOID4VP({authorizationRequest, res});
625
- }
626
- // otherwise drop down below to complete exchange...
627
- } else if(jwtDidProofRequest) {
628
- // handle OID4VCI specialized JWT DID Proof request...
629
-
630
- // `proof` must be in every credential request; if any requets is missing
631
- // `proof` then request a DID proof
632
- if(credentialRequests.some(cr => !cr.proof?.jwt)) {
633
- return _requestDidProof({res, exchangeRecord});
634
- }
635
-
636
- // verify every DID proof and get resulting DIDs
637
- const results = await Promise.all(
638
- credentialRequests.map(async cr => {
639
- const {proof: {jwt}} = cr;
640
- const {did} = await verifyDidProofJwt({workflow, exchange, jwt});
641
- return did;
642
- }));
643
- // require `did` to be the same for every proof
644
- // FIXME: determine if this needs to be more flexible
645
- const did = results[0];
646
- if(results.some(d => did !== d)) {
647
- // FIXME: improve error
648
- throw new Error('every DID must be the same');
649
- }
650
- // store did results in variables associated with current step
651
- if(!exchange.variables.results) {
652
- exchange.variables.results = {};
653
- }
654
- exchange.variables.results[currentStep] = {
655
- // common use case of DID Authentication; provide `did` for ease
656
- // of use in templates
657
- did
658
- };
659
- }
660
- }
661
-
662
- // mark exchange complete
663
- exchange.sequence++;
664
- await exchanges.complete({workflowId: workflow.id, exchange});
665
-
666
- // FIXME: decide what the best recovery path is if delivery fails (but no
667
- // replay attack detected) after exchange has been marked complete
668
-
669
- // issue VCs
670
- return issue({workflow, exchange, format});
671
- }
672
-
673
- async function _requestDidProof({res, exchangeRecord}) {
674
- /* `9.4 Credential Issuer-provided nonce` allows the credential
675
- issuer infrastructure to provide the nonce via an error:
676
-
677
- HTTP/1.1 400 Bad Request
678
- Content-Type: application/json
679
- Cache-Control: no-store
680
-
681
- {
682
- "error": "invalid_or_missing_proof"
683
- "error_description":
684
- "Credential issuer requires proof element in Credential Request"
685
- "c_nonce": "8YE9hCnyV2",
686
- "c_nonce_expires_in": 86400
687
- }*/
688
-
689
- /* OID4VCI exchanges themselves are not replayable and single-step, so the
690
- challenge to be signed is just the exchange ID itself. An exchange cannot
691
- be reused and neither can a challenge. */
692
- const {exchange, meta: {expires}} = exchangeRecord;
693
- const ttl = Math.floor((expires.getTime() - Date.now()) / 1000);
694
-
695
- res.status(400).json({
696
- error: 'invalid_or_missing_proof',
697
- error_description:
698
- 'Credential issuer requires proof element in Credential Request',
699
- // use exchange ID
700
- c_nonce: exchange.id,
701
- // use exchange expiration period
702
- c_nonce_expires_in: ttl
703
- });
704
- }
705
-
706
- async function _requestOID4VP({authorizationRequest, res}) {
707
- /* Error thrown when OID4VP is required to complete OID4VCI:
708
-
709
- HTTP/1.1 400 Bad Request
710
- Content-Type: application/json
711
- Cache-Control: no-store
712
-
713
- {
714
- "error": "presentation_required"
715
- "error_description":
716
- "Credential issuer requires presentation before Credential Request"
717
- "authorization_request": {
718
- "response_type": "vp_token",
719
- "presentation_definition": {
720
- id: "<urn:uuid>",
721
- input_descriptors: {...}
722
- },
723
- "response_mode": "direct_post"
724
- }
725
- }*/
726
-
727
- /* OID4VCI exchanges themselves are not replayable and single-step, so the
728
- challenge to be signed is just the exchange ID itself. An exchange cannot
729
- be reused and neither can a challenge. */
730
-
731
- res.status(400).json({
732
- error: 'presentation_required',
733
- error_description:
734
- 'Credential issuer requires presentation before Credential Request',
735
- authorization_request: authorizationRequest
736
- });
737
- }
738
-
739
- async function _getAuthorizationRequest({req}) {
740
- const {config: workflow} = req.serviceObject;
741
- const exchangeRecord = await req.getExchange();
742
- let {exchange} = exchangeRecord;
743
- let step;
744
-
745
- while(true) {
746
- // exchange step required for OID4VP
747
- const currentStep = exchange.step;
748
- if(!currentStep) {
749
- _throwUnsupportedProtocol();
750
- }
751
-
752
- step = workflow.steps[exchange.step];
753
- if(step.stepTemplate) {
754
- // generate step from the template; assume the template type is
755
- // `jsonata` per the JSON schema
756
- step = await evaluateTemplate(
757
- {workflow, exchange, typedTemplate: step.stepTemplate});
758
- if(Object.keys(step).length === 0) {
759
- throw new BedrockError('Could not create authorization request.', {
760
- name: 'DataError',
761
- details: {httpStatusCode: 500, public: true}
762
- });
763
- }
764
- }
765
-
766
- // step must have `openId` to perform OID4VP
767
- if(!step.openId) {
768
- _throwUnsupportedProtocol();
769
- }
770
-
771
- let updateExchange = false;
772
-
773
- if(exchange.state === 'pending') {
774
- exchange.state = 'active';
775
- updateExchange = true;
776
- }
777
-
778
- // get authorization request
779
- let authorizationRequest = step.openId.authorizationRequest;
780
- if(!authorizationRequest) {
781
- // create authorization request...
782
- // get variable name for authorization request
783
- const authzReqName = step.openId.createAuthorizationRequest;
784
- if(authzReqName === undefined) {
785
- _throwUnsupportedProtocol();
786
- }
787
-
788
- // create or get cached authorization request
789
- authorizationRequest = exchange.variables?.[authzReqName];
790
- if(!authorizationRequest) {
791
- const {verifiablePresentationRequest} = step;
792
- authorizationRequest = oid4vp.fromVpr({verifiablePresentationRequest});
793
-
794
- // add / override params from step `openId` information
795
- const {
796
- client_id, client_id_scheme,
797
- client_metadata, client_metadata_uri,
798
- nonce, response_uri
799
- } = step.openId || {};
800
- if(client_id) {
801
- authorizationRequest.client_id = client_id;
802
- } else {
803
- authorizationRequest.client_id =
804
- `${workflow.id}/exchanges/${exchange.id}` +
805
- '/openid/client/authorization/response';
806
- }
807
- if(client_id_scheme) {
808
- authorizationRequest.client_id_scheme = client_id_scheme;
809
- } else if(authorizationRequest.client_id_scheme === undefined) {
810
- authorizationRequest.client_id_scheme = 'redirect_uri';
811
- }
812
- if(client_metadata) {
813
- authorizationRequest.client_metadata = klona(client_metadata);
814
- } else if(client_metadata_uri) {
815
- authorizationRequest.client_metadata_uri = client_metadata_uri;
816
- }
817
- if(nonce) {
818
- authorizationRequest.nonce = nonce;
819
- } else if(authorizationRequest.nonce === undefined) {
820
- // if no nonce has been set for the authorization request, use the
821
- // exchange ID
822
- authorizationRequest.nonce = exchange.id;
823
- }
824
- if(response_uri) {
825
- authorizationRequest.response_uri = response_uri;
826
- } else if(authorizationRequest.response_mode === 'direct_post' &&
827
- authorizationRequest.client_id_scheme === 'redirect_uri') {
828
- // `authorizationRequest` uses `direct_post` so force client ID to
829
- // be the exchange response URL per "Note" here:
830
- // eslint-disable-next-line max-len
831
- // https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#section-6.2
832
- authorizationRequest.response_uri = authorizationRequest.client_id;
833
- }
834
-
835
- // store generated authorization request
836
- updateExchange = true;
837
- if(!exchange.variables) {
838
- exchange.variables = {};
839
- }
840
- exchange.variables[authzReqName] = authorizationRequest;
841
- }
842
- }
843
-
844
- if(updateExchange) {
845
- exchange.sequence++;
846
- try {
847
- await exchanges.update({workflowId: workflow.id, exchange});
848
- } catch(e) {
849
- if(e.name !== 'InvalidStateError') {
850
- // unrecoverable error
851
- throw e;
852
- }
853
- // get exchange and loop to try again on `InvalidStateError`
854
- const record = await exchanges.get(
855
- {workflowId: workflow.id, id: exchange.id});
856
- ({exchange} = record);
857
- continue;
858
- }
859
- }
860
-
861
- return {authorizationRequest, exchange, step};
862
- }
863
- }
864
-
865
- function _assertCredentialRequests({
866
- workflow, credentialRequests, expectedCredentialRequests
867
- }) {
868
- // ensure that every credential request is for the same format
869
- /* credential requests look like:
870
- {
871
- format: 'ldp_vc',
872
- credential_definition: { '@context': [Array], type: [Array] }
873
- }
874
- */
875
- let sharedFormat;
876
- if(!credentialRequests.every(({format}) => {
877
- if(sharedFormat === undefined) {
878
- sharedFormat = format;
879
- }
880
- return sharedFormat === format;
881
- })) {
882
- throw new BedrockError(
883
- 'Credential requests must all use the same format in this workflow.', {
884
- name: 'DataError',
885
- details: {httpStatusCode: 400, public: true}
886
- });
887
- }
888
-
889
- // get all supported formats from available issuer instances; for simple
890
- // workflow configs, a single issuer instance is used with only
891
- // ensure that every credential request uses a format supported by
892
- // issuer instances
893
- const supportedFormats = new Set();
894
- const issuerInstances = getWorkflowIssuerInstances({workflow});
895
- issuerInstances.forEach(
896
- instance => instance.supportedFormats.forEach(
897
- supportedFormats.add, supportedFormats));
898
- if(!supportedFormats.has(sharedFormat)) {
899
- throw new BedrockError(
900
- `Credential request format "${sharedFormat}" is not supported ` +
901
- 'by this workflow.', {
902
- name: 'DataError',
903
- details: {httpStatusCode: 400, public: true}
904
- });
905
- }
906
-
907
- // ensure every credential request matches against an expected one and none
908
- // are missing; `expectedCredentialRequests` formats are ignored based on the
909
- // issuer instance supported formats and have already been checked
910
- if(!(credentialRequests.length === expectedCredentialRequests.length &&
911
- credentialRequests.every(cr => expectedCredentialRequests.some(
912
- expected => _matchCredentialRequest(expected, cr))))) {
913
- throw new BedrockError(
914
- 'Unexpected credential request.', {
915
- name: 'DataError',
916
- details: {httpStatusCode: 400, public: true}
917
- });
918
- }
919
-
920
- return {format: sharedFormat};
921
- }
922
-
923
- function _matchCredentialRequest(expected, cr) {
924
- const {credential_definition: {'@context': c1, type: t1}} = expected;
925
- const {credential_definition: {'@context': c2, type: t2}} = cr;
926
- // contexts must match exact order but types can have different order
927
- return (c1.length === c2.length && t1.length === t2.length &&
928
- c1.every((c, i) => c === c2[i]) && t1.every(t => t2.some(x => t === x)));
929
- }
930
-
931
- async function _processAuthorizationResponse({
932
- req, presentation, envelope, presentationSubmission
933
- }) {
934
- const {config: workflow} = req.serviceObject;
935
- const exchangeRecord = await req.getExchange();
936
- let {exchange} = exchangeRecord;
937
-
938
- // get authorization request and updated exchange associated with exchange
939
- const arRequest = await _getAuthorizationRequest({req});
940
- const {authorizationRequest, step} = arRequest;
941
- ({exchange} = arRequest);
942
-
943
- // FIXME: check the VP against the presentation submission if requested
944
- // FIXME: check the VP against "trustedIssuer" in VPR, if provided
945
- const {presentationSchema} = step;
946
- if(presentationSchema) {
947
- // if the VP is enveloped, validate the contents of the envelope
948
- const toValidate = envelope ? envelope.contents : presentation;
949
-
950
- // validate the received VP / envelope contents
951
- const {jsonSchema: schema} = presentationSchema;
952
- const validate = compile({schema});
953
- const {valid, error} = validate(toValidate);
954
- if(!valid) {
955
- throw error;
956
- }
957
- }
958
-
959
- // verify the received VP
960
- const {verifiablePresentationRequest} = await oid4vp.toVpr(
961
- {authorizationRequest});
962
- const {allowUnprotectedPresentation = false} = step;
963
- const verifyResult = await verify({
964
- workflow,
965
- verifiablePresentationRequest,
966
- presentation,
967
- allowUnprotectedPresentation,
968
- expectedChallenge: authorizationRequest.nonce
969
- });
970
- const {verificationMethod} = verifyResult;
971
-
972
- // store VP results in variables associated with current step
973
- const currentStep = exchange.step;
974
- if(!exchange.variables.results) {
975
- exchange.variables.results = {};
976
- }
977
- const results = {
978
- // common use case of DID Authentication; provide `did` for ease
979
- // of use in template
980
- did: verificationMethod?.controller || null,
981
- verificationMethod,
982
- verifiablePresentation: presentation,
983
- openId: {
984
- authorizationRequest,
985
- presentationSubmission
986
- }
987
- };
988
- if(envelope) {
989
- // normalize VP from inside envelope to `verifiablePresentation`
990
- results.envelopedPresentation = presentation;
991
- results.verifiablePresentation = verifyResult
992
- .presentationResult.presentation;
993
- }
994
- exchange.variables.results[currentStep] = results;
995
- exchange.sequence++;
996
-
997
- // if there is something to issue, update exchange, do not complete it
998
- const {credentialTemplates = []} = workflow;
999
- if(credentialTemplates?.length > 0 &&
1000
- (exchange.state === 'pending' || exchange.state === 'active')) {
1001
- // ensure exchange state is set to `active` (will be rejected as a
1002
- // conflict if the state in database at update time isn't `pending` or
1003
- // `active`)
1004
- exchange.state = 'active';
1005
- await exchanges.update({workflowId: workflow.id, exchange});
1006
- } else {
1007
- // mark exchange complete
1008
- await exchanges.complete({workflowId: workflow.id, exchange});
1009
- }
1010
-
1011
- const result = {};
1012
-
1013
- // include `redirect_uri` if specified in step
1014
- const redirect_uri = step.openId?.redirect_uri;
1015
- if(redirect_uri) {
1016
- result.redirect_uri = redirect_uri;
1017
- }
1018
-
1019
- return result;
1020
- }
1021
-
1022
- function _jsonParse(x, name) {
1023
- try {
1024
- return JSON.parse(x);
1025
- } catch(cause) {
1026
- throw new BedrockError(`Could not parse "${name}".`, {
1027
- name: 'DataError',
1028
- details: {httpStatusCode: 400, public: true},
1029
- cause
1030
- });
1031
- }
1032
- }
1033
-
1034
- function _throwUnsupportedProtocol() {
1035
- // FIXME: improve error
1036
- // unsupported protocol for the exchange
1037
- throw new Error('Unsupported protocol.');
1038
- }
1039
-
1040
- function _validate(validator, data) {
1041
- const result = validator(data);
1042
- if(!result.valid) {
1043
- throw result.error;
1044
- }
1045
- }
1046
-
1047
- function _normalizeCredentialDefinitionTypes({credentialRequests}) {
1048
- // normalize credential requests to use `type` instead of `types`
1049
- for(const cr of credentialRequests) {
1050
- if(cr?.credential_definition?.types) {
1051
- if(!cr?.credential_definition?.type) {
1052
- cr.credential_definition.type = cr.credential_definition.types;
1053
- }
1054
- delete cr.credential_definition.types;
1055
- }
1056
- }
1057
- }