@digitalbazaar/oid4-client 3.0.1 → 3.2.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/README.md CHANGED
@@ -1,2 +1,141 @@
1
- # oid4-client
2
- An OID4 client
1
+ # OID4Client Library _(@digitalbazaar/oid4-client)_
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/@digitalbazaar/oid4-client.svg)](https://npm.im/@digitalbazaar/oid4-client)
4
+
5
+ A JavaScript library for working with the OpenID 4 Verifiable Credential
6
+ Issuance (OID4VCI) protocol, offering functionality for requesting Verifiable
7
+ Credentials.
8
+
9
+ ## Table of Contents
10
+
11
+ - [Background](#background)
12
+ - [Install](#install)
13
+ - [Usage](#usage)
14
+ - [Testing](#testing)
15
+ - [Contribute](#contribute)
16
+ - [Commercial Support](#commercial-support)
17
+ - [License](#license)
18
+
19
+ ## Background
20
+
21
+ This library is a JavaScript (Node.js and browser) implementation of the
22
+ [OID4VCI v11](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html)
23
+ Protocol.
24
+
25
+ It allows you to perform the following operations:
26
+
27
+ 1. Request a credential to be issued given a OID4VCI credential offer.
28
+ 2. Request multiple credentials to be issued given a OID4VCI credential offer.
29
+ 3. Authenticate using a DID if the offer requires it.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install @digitalbazaar/oid4-client
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Importing the Library
40
+
41
+ ```javascript
42
+ import { OID4Client } from "@digitalbazaar/oid4-client";
43
+ ```
44
+
45
+
46
+ ### Creating a Client from a Credential Offer
47
+
48
+ ```javascript
49
+ const clientFromOffer = await OID4Client.fromCredentialOffer({
50
+ offer: "YOUR_CREDENTIAL_OFFER",
51
+ });
52
+ ```
53
+
54
+ ### Constructor
55
+
56
+ You can also instantiate the `OID4Client` directly using the following parameters:
57
+
58
+ - `accessToken` (Optional)
59
+ - `issuerConfig`
60
+ - `metadata`
61
+ - `offer`
62
+
63
+ Example:
64
+
65
+ ```javascript
66
+ const client = new OID4Client({
67
+ accessToken: "YOUR_ACCESS_TOKEN",
68
+ issuerConfig: "YOUR_ISSUER_CONFIG",
69
+ metadata: "YOUR_METADATA",
70
+ offer: "YOUR_OFFER",
71
+ });
72
+ ```
73
+
74
+ ### Requesting a Credential
75
+
76
+ To request a single credential using the credential offer:
77
+
78
+ ```javascript
79
+ const credential = await client.requestCredential({
80
+ did: "YOUR_DID",
81
+ didProofSigner: "YOUR_DID_PROOF_SIGNER",
82
+ });
83
+ ```
84
+
85
+ To request multiple credentials using the credential offer:
86
+
87
+ ```javascript
88
+ const credentials = await client.requestCredentials({
89
+ did: "YOUR_DID",
90
+ didProofSigner: "YOUR_DID_PROOF_SIGNER",
91
+ });
92
+ ```
93
+
94
+ ### Requesting a Credential By Definition
95
+
96
+ To request a single credential using a specific credential definition:
97
+
98
+ ```javascript
99
+ const credential = await client.requestCredential({
100
+ credentialDefinition: "YOUR_CREDENTIAL_DEFINITION",
101
+ did: "YOUR_DID",
102
+ didProofSigner: "YOUR_DID_PROOF_SIGNER",
103
+ });
104
+ ```
105
+
106
+ To request multiple credentials using credential definition requests:
107
+
108
+ ```javascript
109
+ const credentials = await client.requestCredentials({
110
+ requests: "YOUR_REQUESTS",
111
+ did: "YOUR_DID",
112
+ didProofSigner: "YOUR_DID_PROOF_SIGNER",
113
+ });
114
+ ```
115
+
116
+ ## Testing
117
+
118
+ To run tests:
119
+
120
+ ```
121
+ npm run test
122
+ ```
123
+
124
+ ## Contribute
125
+
126
+ See
127
+ [the contribute file](https://github.com/digitalbazaar/bedrock/blob/master/CONTRIBUTING.md)!
128
+
129
+ PRs accepted.
130
+
131
+ Note: If editing the Readme, please conform to the
132
+ [standard-readme](https://github.com/RichardLitt/standard-readme) specification.
133
+
134
+ ## Commercial Support
135
+
136
+ Commercial support for this library is available upon request from Digital
137
+ Bazaar: support@digitalbazaar.com
138
+
139
+ ## License
140
+
141
+ [New BSD License (3-clause)](LICENSE) © Digital Bazaar
package/lib/OID4Client.js CHANGED
@@ -128,9 +128,8 @@ export class OID4Client {
128
128
  // if `didProofSigner` is not provided, throw error
129
129
  if(!(did && didProofSigner)) {
130
130
  const {data: details} = cause;
131
- const error = new Error('DID authentication is required.');
131
+ const error = new Error('DID authentication is required.', {cause});
132
132
  error.name = 'NotAllowedError';
133
- error.cause = cause;
134
133
  error.details = details;
135
134
  throw error;
136
135
  }
@@ -188,9 +187,8 @@ export class OID4Client {
188
187
  */
189
188
  return result;
190
189
  } catch(cause) {
191
- const error = new Error('Could not receive credentials.');
190
+ const error = new Error('Could not receive credentials.', {cause});
192
191
  error.name = 'OperationError';
193
- error.cause = cause;
194
192
  throw error;
195
193
  }
196
194
  }
@@ -205,10 +203,8 @@ export class OID4Client {
205
203
  if(parsedIssuer.protocol !== 'https:') {
206
204
  throw new Error('Only "https" credential issuer URLs are supported.');
207
205
  }
208
- } catch(e) {
209
- const err = new Error('"offer.credential_issuer" is not valid.');
210
- err.cause = e;
211
- throw err;
206
+ } catch(cause) {
207
+ throw new Error('"offer.credential_issuer" is not valid.', {cause});
212
208
  }
213
209
  if(!(Array.isArray(credentials) && credentials.length > 0 &&
214
210
  credentials.every(c => c && typeof c === 'object'))) {
@@ -303,9 +299,8 @@ export class OID4Client {
303
299
  return new OID4Client(
304
300
  {accessToken, agent, issuerConfig, metadata, offer});
305
301
  } catch(cause) {
306
- const error = new Error('Could not create OID4 client.');
302
+ const error = new Error('Could not create OID4 client.', {cause});
307
303
  error.name = 'OperationError';
308
- error.cause = cause;
309
304
  throw error;
310
305
  }
311
306
  }
@@ -342,15 +337,18 @@ function _isMissingProofError(error) {
342
337
  Cache-Control: no-store
343
338
 
344
339
  {
345
- "error": "invalid_or_missing_proof"
340
+ "error": "invalid_or_missing_proof" // or "invalid_proof"
346
341
  "error_description":
347
342
  "Credential issuer requires proof element in Credential Request"
348
343
  "c_nonce": "8YE9hCnyV2",
349
344
  "c_nonce_expires_in": 86400
350
345
  }
351
346
  */
347
+ // `invalid_proof` OID4VCI draft 13+, `invalid_or_missing_proof` earlier
348
+ const errorType = error.data?.error;
352
349
  return error.status === 400 &&
353
- error?.data?.error === 'invalid_or_missing_proof';
350
+ (errorType === 'invalid_proof' ||
351
+ errorType === 'invalid_or_missing_proof');
354
352
  }
355
353
 
356
354
  function _createCredentialRequestFromId({id, issuerConfig}) {
package/lib/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  /*!
2
2
  * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
- export * from './util.js';
4
+ export * as oid4vp from './oid4vp.js';
5
+ export {
6
+ discoverIssuer,
7
+ generateDIDProofJWT,
8
+ parseCredentialOfferUrl,
9
+ signJWT
10
+ } from './util.js';
5
11
  export {OID4Client} from './OID4Client.js';
package/lib/oid4vp.js ADDED
@@ -0,0 +1,822 @@
1
+ /*!
2
+ * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {assert, assertOptional, fetchJSON} from './util.js';
5
+ import {decodeJwt} from 'jose';
6
+ import {httpClient} from '@digitalbazaar/http-client';
7
+ import {JSONPath} from 'jsonpath-plus';
8
+ import jsonpointer from 'jsonpointer';
9
+ import {v4 as uuid} from 'uuid';
10
+
11
+ // For examples of presentation request and responses, see:
12
+ // eslint-disable-next-line max-len
13
+ // https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#appendix-A.1.2.2
14
+
15
+ // get an authorization request from a verifier
16
+ export async function getAuthorizationRequest({
17
+ url, agent, documentLoader
18
+ } = {}) {
19
+ try {
20
+ assert(url, 'url', 'string');
21
+ assertOptional(documentLoader, 'documentLoader', 'function');
22
+
23
+ let requestUrl = url;
24
+ let expectedClientId;
25
+ if(url.startsWith('openid4vp://')) {
26
+ const {authorizationRequest} = _parseOID4VPUrl({url});
27
+ if(authorizationRequest.request) {
28
+ const error = new Error(
29
+ 'JWT-Secured Authorization Request (JAR) not implemented.');
30
+ error.name = 'NotSupportedError';
31
+ throw error;
32
+ }
33
+ if(!authorizationRequest.request_uri) {
34
+ // return direct request
35
+ return {authorizationRequest, fetched: false};
36
+ }
37
+ requestUrl = authorizationRequest.request_uri;
38
+ ({client_id: expectedClientId} = authorizationRequest);
39
+ }
40
+
41
+ // FIXME: every `fetchJSON` call needs to use a block list or other
42
+ // protections to prevent a confused deputy attack where the `requestUrl`
43
+ // accesses a location it should not, e.g., is on localhost and should
44
+ // not be used in this way
45
+ const response = await fetchJSON({url: requestUrl, agent});
46
+
47
+ // parse payload from response data...
48
+ const contentType = response.headers.get('content-type');
49
+ const jwt = await response.text();
50
+ // verify response is a JWT-secured authorization request
51
+ if(!(contentType.includes('application/oauth-authz-req+jwt') &&
52
+ typeof jwt === 'string')) {
53
+ const error = new Error(
54
+ 'Authorization request content-type must be ' +
55
+ '"application/oauth-authz-req+jwt".');
56
+ error.name = 'DataError';
57
+ throw error;
58
+ }
59
+
60
+ // decode JWT *WITHOUT* verification
61
+ const payload = decodeJwt(jwt);
62
+
63
+ // validate payload (expected authorization request)
64
+ const {
65
+ client_id,
66
+ client_id_scheme,
67
+ client_metadata,
68
+ client_metadata_uri,
69
+ nonce,
70
+ presentation_definition,
71
+ presentation_definition_uri,
72
+ response_mode,
73
+ scope
74
+ } = payload;
75
+ assert(client_id, 'client_id', 'string');
76
+ // ensure `client_id` matches expected client ID
77
+ if(expectedClientId !== undefined && client_id !== expectedClientId) {
78
+ const error = new Error(
79
+ '"client_id" in fetched request does not match authorization ' +
80
+ 'request URL parameter.');
81
+ error.name = 'DataError';
82
+ throw error;
83
+ }
84
+ assert(nonce, 'nonce', 'string');
85
+ assertOptional(client_id_scheme, 'client_id_scheme', 'string');
86
+ assertOptional(client_metadata, 'client_metadata', 'object');
87
+ assertOptional(client_metadata_uri, 'client_metadata_uri', 'string');
88
+ assertOptional(
89
+ presentation_definition, 'presentation_definition', 'object');
90
+ assertOptional(
91
+ presentation_definition_uri, 'presentation_definition_uri', 'string');
92
+ assertOptional(response_mode, 'response_mode', 'string');
93
+ assertOptional(scope, 'scope', 'string');
94
+ if(client_metadata && client_metadata_uri) {
95
+ const error = new Error(
96
+ 'Only one of "client_metadata" and ' +
97
+ '"client_metadata_uri" must be present.');
98
+ error.name = 'DataError';
99
+ throw error;
100
+ }
101
+ if(presentation_definition && presentation_definition_uri) {
102
+ const error = new Error(
103
+ 'Only one of "presentation_definition" and ' +
104
+ '"presentation_definition_uri" must be present.');
105
+ error.name = 'DataError';
106
+ throw error;
107
+ }
108
+ // Note: This implementation requires `response_mode` to be `direct_post`,
109
+ // no other modes are supported.
110
+ if(response_mode !== 'direct_post') {
111
+ const error = new Error(
112
+ 'Only "direct_post" response mode is supported.');
113
+ error.name = 'NotSupportedError';
114
+ throw error;
115
+ }
116
+
117
+ // build merged authorization request
118
+ const authorizationRequest = {...payload};
119
+
120
+ // get client meta data from URL if specified
121
+ if(client_metadata_uri) {
122
+ const response = await fetchJSON({url: client_metadata_uri, agent});
123
+ if(!response.data) {
124
+ const error = new Error('Client meta data format is not JSON.');
125
+ error.name = 'DataError';
126
+ throw error;
127
+ }
128
+ // FIXME: can `data` be a JWT and require verification as well?
129
+ delete authorizationRequest.client_metadata_uri;
130
+ authorizationRequest.client_metadata = response.data;
131
+ }
132
+
133
+ // get presentation definition from URL if not embedded
134
+ if(presentation_definition_uri) {
135
+ const response = await fetchJSON(
136
+ {url: presentation_definition_uri, agent});
137
+ if(!response.data) {
138
+ const error = new Error('Presentation definition format is not JSON.');
139
+ error.name = 'DataError';
140
+ throw error;
141
+ }
142
+ // FIXME: can `data` be a JWT and require verification as well?
143
+ delete authorizationRequest.presentation_definition_uri;
144
+ authorizationRequest.presentation_definition = response.data;
145
+ }
146
+
147
+ // FIXME: validate `authorizationRequest.presentation_definition`
148
+
149
+ // return merged authorization request and original response
150
+ return {authorizationRequest, fetched: true, requestUrl, response, jwt};
151
+ } catch(cause) {
152
+ const error = new Error('Could not get authorization request.', {cause});
153
+ error.name = 'OperationError';
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ export async function sendAuthorizationResponse({
159
+ verifiablePresentation,
160
+ presentationSubmission,
161
+ authorizationRequest,
162
+ agent
163
+ } = {}) {
164
+ try {
165
+ // if no `presentationSubmission` provided, auto-generate one
166
+ let generatedPresentationSubmission = false;
167
+ if(!presentationSubmission) {
168
+ ({presentationSubmission} = createPresentationSubmission({
169
+ presentationDefinition: authorizationRequest.presentation_definition,
170
+ verifiablePresentation
171
+ }));
172
+ generatedPresentationSubmission = true;
173
+ }
174
+
175
+ // send VP and presentation submission to complete exchange
176
+ const body = new URLSearchParams();
177
+ body.set('vp_token', JSON.stringify(verifiablePresentation));
178
+ body.set('presentation_submission', JSON.stringify(presentationSubmission));
179
+ const response = await httpClient.post(authorizationRequest.response_uri, {
180
+ agent, body, headers: {accept: 'application/json'},
181
+ // FIXME: limit response size
182
+ // timeout in ms for response
183
+ timeout: 5000
184
+ });
185
+ // return response data as `result`
186
+ const result = response.data || {};
187
+ if(generatedPresentationSubmission) {
188
+ // return any generated presentation submission
189
+ return {result, presentationSubmission};
190
+ }
191
+ return {result};
192
+ } catch(cause) {
193
+ const error = new Error(
194
+ 'Could not send OID4VP authorization response.', {cause});
195
+ error.name = 'OperationError';
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ // converts an OID4VP authorization request (including its
201
+ // "presentation definition") to a VPR
202
+ export async function toVpr({
203
+ authorizationRequest, strict = false, agent
204
+ } = {}) {
205
+ try {
206
+ const {
207
+ client_id,
208
+ client_metadata_uri,
209
+ nonce,
210
+ presentation_definition_uri,
211
+ } = authorizationRequest;
212
+ let {
213
+ client_metadata,
214
+ presentation_definition
215
+ } = authorizationRequest;
216
+ if(client_metadata && client_metadata_uri) {
217
+ const error = new Error(
218
+ 'Only one of "client_metadata" and ' +
219
+ '"client_metadata_uri" must be present.');
220
+ error.name = 'DataError';
221
+ throw error;
222
+ }
223
+ if(presentation_definition && presentation_definition_uri) {
224
+ const error = new Error(
225
+ 'Only one of "presentation_definition" and ' +
226
+ '"presentation_definition_uri" must be present.');
227
+ error.name = 'DataError';
228
+ throw error;
229
+ }
230
+
231
+ // apply constraints for currently supported subset of AR data
232
+ if(client_metadata_uri) {
233
+ const response = await fetchJSON({url: client_metadata_uri, agent});
234
+ if(!response.data) {
235
+ const error = new Error('Client metadata format is not JSON.');
236
+ error.name = 'DataError';
237
+ throw error;
238
+ }
239
+ client_metadata = response.data;
240
+ }
241
+ assertOptional(client_metadata, 'client_metadata', 'object');
242
+ if(presentation_definition_uri) {
243
+ const response = await fetchJSON(
244
+ {url: presentation_definition_uri, agent});
245
+ if(!response.data) {
246
+ const error = new Error('Presentation definition format is not JSON.');
247
+ error.name = 'DataError';
248
+ throw error;
249
+ }
250
+ presentation_definition = response.data;
251
+ }
252
+ assert(presentation_definition, 'presentation_definition', 'object');
253
+ assert(presentation_definition?.id, 'presentation_definition.id', 'string');
254
+ if(presentation_definition.submission_requirements && strict) {
255
+ const error = new Error('"submission_requirements" is not supported.');
256
+ error.name = 'NotSupportedError';
257
+ throw error;
258
+ }
259
+
260
+ // generate base VPR from presentation definition
261
+ const verifiablePresentationRequest = {
262
+ // map each `input_descriptors` value to a `QueryByExample` query
263
+ query: [{
264
+ type: 'QueryByExample',
265
+ credentialQuery: presentation_definition.input_descriptors.map(
266
+ inputDescriptor => _toQueryByExampleQuery({inputDescriptor, strict}))
267
+ }]
268
+ };
269
+
270
+ // add `DIDAuthentication` query based on client_metadata
271
+ if(client_metadata) {
272
+ const query = _toDIDAuthenticationQuery({client_metadata, strict});
273
+ if(query !== undefined) {
274
+ verifiablePresentationRequest.query.unshift(query);
275
+ }
276
+ }
277
+
278
+ // map `client_id` to `domain`
279
+ if(client_id !== undefined) {
280
+ verifiablePresentationRequest.domain = client_id;
281
+ }
282
+
283
+ // map `nonce` to `challenge`
284
+ if(nonce !== undefined) {
285
+ verifiablePresentationRequest.challenge = nonce;
286
+ }
287
+
288
+ return {verifiablePresentationRequest};
289
+ } catch(cause) {
290
+ const error = new Error(
291
+ 'Could not convert OID4VP authorization request to ' +
292
+ 'verifiable presentation request.', {cause});
293
+ error.name = 'OperationError';
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ // converts a VPR to partial "authorization request"
299
+ export function fromVpr({
300
+ verifiablePresentationRequest, strict = false, prefixJwtVcPath = false
301
+ } = {}) {
302
+ try {
303
+ let {query} = verifiablePresentationRequest;
304
+ if(!Array.isArray(query)) {
305
+ query = [query];
306
+ }
307
+
308
+ // convert any `QueryByExample` queries
309
+ const queryByExample = query.filter(({type}) => type === 'QueryByExample');
310
+ let credentialQuery = [];
311
+ if(queryByExample.length > 0) {
312
+ if(queryByExample.length > 1 && strict) {
313
+ const error = new Error(
314
+ 'Multiple "QueryByExample" VPR queries are not supported.');
315
+ error.name = 'NotSupportedError';
316
+ throw error;
317
+ }
318
+ ([{credentialQuery = []}] = queryByExample);
319
+ if(!Array.isArray(credentialQuery)) {
320
+ credentialQuery = [credentialQuery];
321
+ }
322
+ }
323
+ const authorizationRequest = {
324
+ response_type: 'vp_token',
325
+ presentation_definition: {
326
+ id: uuid(),
327
+ input_descriptors: credentialQuery.map(q => _fromQueryByExampleQuery({
328
+ credentialQuery: q,
329
+ prefixJwtVcPath
330
+ }))
331
+ },
332
+ response_mode: 'direct_post'
333
+ };
334
+
335
+ // convert any `DIDAuthentication` queries
336
+ const didAuthnQuery = query.filter(
337
+ ({type}) => type === 'DIDAuthentication');
338
+ if(didAuthnQuery.length > 0) {
339
+ if(didAuthnQuery.length > 1 && strict) {
340
+ const error = new Error(
341
+ 'Multiple "DIDAuthentication" VPR queries are not supported.');
342
+ error.name = 'NotSupportedError';
343
+ throw error;
344
+ }
345
+ const [query] = didAuthnQuery;
346
+ const client_metadata = _fromDIDAuthenticationQuery({query, strict});
347
+ authorizationRequest.client_metadata = client_metadata;
348
+ }
349
+
350
+ if(queryByExample.length === 0 && didAuthnQuery.length === 0 && strict) {
351
+ const error = new Error(
352
+ 'Only "DIDAuthentication" and "QueryByExample" VPR queries are ' +
353
+ 'supported.');
354
+ error.name = 'NotSupportedError';
355
+ throw error;
356
+ }
357
+
358
+ // include requested authn params
359
+ if(verifiablePresentationRequest.domain) {
360
+ // `authorizationRequest` uses `direct_post` so force client ID to
361
+ // be the exchange response URL per "Note" here:
362
+ // eslint-disable-next-line max-len
363
+ // https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#section-6.2
364
+ authorizationRequest.client_id = verifiablePresentationRequest.domain;
365
+ authorizationRequest.client_id_scheme = 'redirect_uri';
366
+ authorizationRequest.response_uri = authorizationRequest.client_id;
367
+ }
368
+ if(verifiablePresentationRequest.challenge) {
369
+ authorizationRequest.nonce = verifiablePresentationRequest.challenge;
370
+ }
371
+
372
+ return authorizationRequest;
373
+ } catch(cause) {
374
+ const error = new Error(
375
+ 'Could not convert verifiable presentation request to ' +
376
+ 'an OID4VP authorization request.', {cause});
377
+ error.name = 'OperationError';
378
+ throw error;
379
+ }
380
+ }
381
+
382
+ // creates a "presentation submission" from a presentation definition and VP
383
+ export function createPresentationSubmission({
384
+ presentationDefinition, verifiablePresentation
385
+ } = {}) {
386
+ const descriptor_map = [];
387
+ const presentationSubmission = {
388
+ id: uuid(),
389
+ definition_id: presentationDefinition.id,
390
+ descriptor_map
391
+ };
392
+
393
+ try {
394
+ // walk through each input descriptor object and match it to a VC
395
+ let {verifiableCredential: vcs} = verifiablePresentation;
396
+ const single = !Array.isArray(vcs);
397
+ if(single) {
398
+ vcs = [vcs];
399
+ }
400
+ /* Note: It is conceivable that the same VC could match multiple input
401
+ descriptors. In this simplistic implementation, the first VC that matches
402
+ is used. This may result in VCs in the VP not being mapped to an input
403
+ descriptor, but every input descriptor having a VC that matches (i.e., at
404
+ least one VC will be shared across multiple input descriptors). If
405
+ some other behavior is more desirable, this can be changed in a future
406
+ version. */
407
+ for(const inputDescriptor of presentationDefinition.input_descriptors) {
408
+ // walk through each VC and try to match it to the input descriptor
409
+ for(let i = 0; i < vcs.length; ++i) {
410
+ const verifiableCredential = vcs[i];
411
+ if(_matchesInputDescriptor({inputDescriptor, verifiableCredential})) {
412
+ descriptor_map.push({
413
+ id: inputDescriptor.id,
414
+ path: '$',
415
+ format: 'ldp_vp',
416
+ path_nested: {
417
+ format: 'ldp_vc',
418
+ path: single ?
419
+ '$.verifiableCredential' :
420
+ '$.verifiableCredential[' + i + ']'
421
+ }
422
+ });
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ } catch(cause) {
428
+ const error = new Error(
429
+ 'Could not create presentation submission.', {cause});
430
+ error.name = 'OperationError';
431
+ throw error;
432
+ }
433
+
434
+ return {presentationSubmission};
435
+ }
436
+
437
+ function _filterToValue({filter, strict = false}) {
438
+ /* Each `filter` has a JSON Schema object. In recognition of the fact that
439
+ a query must be usable by common database engines (including perhaps
440
+ encrypted cloud databases) and of the fact that each JSON Schema object will
441
+ come from an untrusted source (and could have malicious regexes, etc.), only
442
+ simple JSON Schema types are supported:
443
+
444
+ `string`: with `const` or `enum`, `format` is not supported and `pattern` has
445
+ partial support as it will be treated as a simple string not a regex; regex
446
+ is a DoS attack vector
447
+
448
+ `array`: with `items` or `contains` where uses a `string` filter
449
+
450
+ */
451
+ let value;
452
+
453
+ const {type} = filter;
454
+ if(type === 'array') {
455
+ if(filter.contains) {
456
+ if(Array.isArray(filter.contains)) {
457
+ value = filter.contains.map(filter => _filterToValue({filter, strict}));
458
+ } else {
459
+ value = _filterToValue({filter: filter.contains, strict});
460
+ }
461
+ } else if(strict) {
462
+ throw new Error(
463
+ 'Unsupported filter; array filters must use "enum" or "contains" ' +
464
+ 'with a string filter.');
465
+ }
466
+ return value;
467
+ }
468
+ if(type === 'string' || type === undefined) {
469
+ if(filter.const !== undefined) {
470
+ value = filter.const;
471
+ } else if(filter.pattern) {
472
+ value = filter.pattern;
473
+ } else if(filter.enum) {
474
+ value = filter.enum.slice();
475
+ } else if(strict) {
476
+ throw new Error(
477
+ 'Unsupported filter; string filters must use "const" or "pattern".');
478
+ }
479
+ return value;
480
+ }
481
+ if(strict) {
482
+ throw new Error(`Unsupported filter type "${type}".`);
483
+ }
484
+ }
485
+
486
+ function _jsonPathToJsonPointer(jsonPath) {
487
+ return JSONPath.toPointer(JSONPath.toPathArray(jsonPath));
488
+ }
489
+
490
+ function _matchesInputDescriptor({
491
+ inputDescriptor, verifiableCredential, strict = false
492
+ }) {
493
+ // walk through each field ensuring there is a matching value
494
+ const fields = inputDescriptor?.constraints?.fields || [];
495
+ for(const field of fields) {
496
+ const {path, filter, optional} = field;
497
+ if(optional) {
498
+ // skip field, it is optional
499
+ continue;
500
+ }
501
+
502
+ try {
503
+ // each field must have a `path` (which can be a string or an array)
504
+ if(!(Array.isArray(path) || typeof path === 'string')) {
505
+ throw new Error(
506
+ 'Input descriptor field "path" must be a string or array.');
507
+ }
508
+
509
+ // process any filter
510
+ let value = '';
511
+ if(filter !== undefined) {
512
+ value = _filterToValue({filter, strict});
513
+ }
514
+ // no value to match, presume no match
515
+ if(value === undefined) {
516
+ return false;
517
+ }
518
+ // normalize value to array
519
+ if(!Array.isArray(value)) {
520
+ value = [value];
521
+ }
522
+
523
+ // filter out erroneous paths
524
+ let paths = Array.isArray(path) ? path : [path];
525
+ paths = _adjustErroneousPaths(paths);
526
+ // convert each JSON path to a JSON pointer
527
+ const pointers = paths.map(_jsonPathToJsonPointer);
528
+
529
+ // check for a value at at least one path
530
+ for(const pointer of pointers) {
531
+ const existing = jsonpointer.get(verifiableCredential, pointer);
532
+ if(existing === undefined) {
533
+ // VC does not match
534
+ return false;
535
+ }
536
+ // look for at least one matching value in `existing`
537
+ let match = false;
538
+ for(const v of value) {
539
+ if(Array.isArray(existing)) {
540
+ if(existing.includes(v)) {
541
+ match = true;
542
+ break;
543
+ }
544
+ } else if(existing === v) {
545
+ match = true;
546
+ break;
547
+ }
548
+ }
549
+ if(!match) {
550
+ return false;
551
+ }
552
+ }
553
+ } catch(cause) {
554
+ const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
555
+ const error = new Error(
556
+ `Could not process input descriptor field: "${id}".`, {cause});
557
+ error.field = field;
558
+ throw error;
559
+ }
560
+ }
561
+
562
+ return true;
563
+ }
564
+
565
+ // exported for testing purposes only
566
+ export function _fromQueryByExampleQuery({credentialQuery, prefixJwtVcPath}) {
567
+ const fields = [];
568
+ const inputDescriptor = {
569
+ id: uuid(),
570
+ constraints: {fields}
571
+ };
572
+ if(credentialQuery?.reason) {
573
+ inputDescriptor.purpose = credentialQuery?.reason;
574
+ }
575
+ // FIXME: current implementation only supports top-level string/array
576
+ // properties and presumes strings
577
+ const path = ['$'];
578
+ const {example = {}} = credentialQuery || {};
579
+ for(const key in example) {
580
+ const value = example[key];
581
+ path.push(key);
582
+
583
+ const filter = {};
584
+ if(Array.isArray(value)) {
585
+ filter.type = 'array';
586
+ filter.contains = value.map(v => ({
587
+ type: 'string',
588
+ const: v
589
+ }));
590
+ } else if(key === 'type') {
591
+ // special provision for array/string for `type`
592
+ filter.type = 'array',
593
+ filter.contains = {
594
+ type: 'string',
595
+ const: value
596
+ };
597
+ } else {
598
+ filter.type = 'string',
599
+ filter.const = value;
600
+ }
601
+ const fieldsPath = [JSONPath.toPathString(path)];
602
+ // include 'vc' path for queries against JWT payloads instead of VCs
603
+ if(prefixJwtVcPath) {
604
+ const vcPath = [...path];
605
+ vcPath.splice(1, 0, 'vc');
606
+ fieldsPath.push(JSONPath.toPathString(vcPath));
607
+ }
608
+ fields.push({
609
+ path: fieldsPath,
610
+ filter
611
+ });
612
+
613
+ path.pop();
614
+ }
615
+
616
+ return inputDescriptor;
617
+ }
618
+
619
+ function _toDIDAuthenticationQuery({client_metadata, strict = false}) {
620
+ const {vp_formats} = client_metadata;
621
+ const proofTypes = vp_formats?.ldp_vp?.proof_type;
622
+ if(!Array.isArray(proofTypes)) {
623
+ if(strict) {
624
+ const error = new Error(
625
+ '"client_metadata.vp_formats.ldp_vp.proof_type" must be an array to ' +
626
+ 'convert to DIDAuthentication query.');
627
+ error.name = 'NotSupportedError';
628
+ throw error;
629
+ }
630
+ return;
631
+ }
632
+ return {
633
+ type: 'DIDAuthentication',
634
+ acceptedCryptosuites: proofTypes.map(cryptosuite => ({cryptosuite}))
635
+ };
636
+ }
637
+
638
+ function _fromDIDAuthenticationQuery({query, strict = false}) {
639
+ const cryptosuites = query.acceptedCryptosuites?.map(
640
+ ({cryptosuite}) => cryptosuite);
641
+ if(!(cryptosuites && cryptosuites.length > 0)) {
642
+ if(strict) {
643
+ const error = new Error(
644
+ '"query.acceptedCryptosuites" must be a non-array with specified ' +
645
+ 'cryptosuites to convert from a DIDAuthentication query.');
646
+ error.name = 'NotSupportedError';
647
+ throw error;
648
+ }
649
+ return;
650
+ }
651
+ return {
652
+ require_signed_request_object: false,
653
+ vp_formats: {
654
+ ldp_vp: {
655
+ proof_type: cryptosuites
656
+ }
657
+ }
658
+ };
659
+ }
660
+
661
+ function _toQueryByExampleQuery({inputDescriptor, strict = false}) {
662
+ // every input descriptor must have an `id`
663
+ if(typeof inputDescriptor?.id !== 'string') {
664
+ throw new TypeError('Input descriptor "id" must be a string.');
665
+ }
666
+
667
+ const example = {};
668
+ const credentialQuery = {example};
669
+ if(inputDescriptor.purpose) {
670
+ credentialQuery.reason = inputDescriptor.purpose;
671
+ }
672
+
673
+ /* Note: Each input descriptor object is currently mapped to a single example
674
+ query. If multiple possible path values appear for a single field, these will
675
+ be mapped to multiple properties in the example which may or may not be what
676
+ is intended. This behavior could be changed in a future revision if it
677
+ becomes clear there is a better approach. */
678
+
679
+ const fields = inputDescriptor.constraints?.fields || [];
680
+ for(const field of fields) {
681
+ const {path, filter, optional} = field;
682
+ // skip optional fields
683
+ if(optional === true) {
684
+ continue;
685
+ }
686
+
687
+ try {
688
+ // each field must have a `path` (which can be a string or an array)
689
+ if(!(Array.isArray(path) || typeof path === 'string')) {
690
+ throw new TypeError(
691
+ 'Input descriptor field "path" must be a string or array.');
692
+ }
693
+
694
+ // process any filter
695
+ let value = '';
696
+ if(filter !== undefined) {
697
+ value = _filterToValue({filter, strict});
698
+ }
699
+ // no value understood, skip field
700
+ if(value === undefined) {
701
+ continue;
702
+ }
703
+ // normalize value to array
704
+ if(!Array.isArray(value)) {
705
+ value = [value];
706
+ }
707
+
708
+ // filter out erroneous paths
709
+ let paths = Array.isArray(path) ? path : [path];
710
+ paths = _adjustErroneousPaths(paths);
711
+ // convert each JSON path to a JSON pointer
712
+ const pointers = paths.map(_jsonPathToJsonPointer);
713
+
714
+ // add values at each path, converting to an array / appending as needed
715
+ for(const pointer of pointers) {
716
+ const existing = jsonpointer.get(example, pointer);
717
+ if(existing === undefined) {
718
+ jsonpointer.set(
719
+ example, pointer, value.length > 1 ? value : value[0]);
720
+ } else if(Array.isArray(existing)) {
721
+ if(!existing.includes(value)) {
722
+ existing.push(...value);
723
+ }
724
+ } else if(existing !== value) {
725
+ jsonpointer.set(example, pointer, [existing, ...value]);
726
+ }
727
+ }
728
+ } catch(cause) {
729
+ const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
730
+ const error = new Error(
731
+ `Could not process input descriptor field: "${id}".`, {cause});
732
+ error.field = field;
733
+ throw error;
734
+ }
735
+ }
736
+
737
+ return credentialQuery;
738
+ }
739
+
740
+ function _adjustErroneousPaths(paths) {
741
+ // remove any paths that start with what would be present in a
742
+ // presentation submission and adjust any paths that would be part of a
743
+ // JWT-secured VC, such that only actual VC paths remain
744
+ const removed = paths.filter(p => !_isPresentationSubmissionPath(p));
745
+ return removed.map(p => {
746
+ if(_isJWTPath(p)) {
747
+ return '$' + p.slice('$.vc'.length);
748
+ }
749
+ if(_isSquareJWTPath(p)) {
750
+ return '$' + p.slice('$[\'vc\']'.length);
751
+ }
752
+ return p;
753
+ });
754
+ }
755
+
756
+ function _parseOID4VPUrl({url}) {
757
+ const {searchParams} = new URL(url);
758
+ const request = _get(searchParams, 'request');
759
+ const request_uri = _get(searchParams, 'request_uri');
760
+ const response_type = _get(searchParams, 'response_type');
761
+ const response_mode = _get(searchParams, 'response_mode');
762
+ const presentation_definition = _get(
763
+ searchParams, 'presentation_definition');
764
+ const presentation_definition_uri = _get(
765
+ searchParams, 'presentation_definition_uri');
766
+ const client_id = _get(searchParams, 'client_id');
767
+ const client_id_scheme = _get(searchParams, 'client_id_scheme');
768
+ const client_metadata = _get(searchParams, 'client_metadata');
769
+ const nonce = _get(searchParams, 'nonce');
770
+ const response_uri = _get(searchParams, 'response_uri');
771
+ const state = _get(searchParams, 'state');
772
+ if(request && request_uri) {
773
+ const error = new Error(
774
+ 'Only one of "request" and "request_uri" may be present.');
775
+ error.name = 'DataError';
776
+ error.url = url;
777
+ throw error;
778
+ }
779
+ if(!(request || request_uri)) {
780
+ if(response_type !== 'vp_token') {
781
+ throw new Error(`Unsupported "response_type", "${response_type}".`);
782
+ }
783
+ if(response_mode !== 'direct_post') {
784
+ throw new Error(`Unsupported "response_type", "${response_type}".`);
785
+ }
786
+ }
787
+ const authorizationRequest = {
788
+ request,
789
+ request_uri,
790
+ response_type,
791
+ response_mode,
792
+ presentation_definition: presentation_definition &&
793
+ JSON.parse(presentation_definition),
794
+ presentation_definition_uri,
795
+ client_id,
796
+ client_id_scheme,
797
+ client_metadata: client_metadata && JSON.parse(client_metadata),
798
+ response_uri,
799
+ nonce,
800
+ state
801
+ };
802
+ return {authorizationRequest};
803
+ }
804
+
805
+ function _get(sp, name) {
806
+ const value = sp.get(name);
807
+ return value === null ? undefined : value;
808
+ }
809
+
810
+ function _isPresentationSubmissionPath(path) {
811
+ return path.startsWith('$.verifiableCredential[') ||
812
+ path.startsWith('$.vp.') ||
813
+ path.startsWith('$[\'verifiableCredential') || path.startsWith('$[\'vp');
814
+ }
815
+
816
+ function _isJWTPath(path) {
817
+ return path.startsWith('$.vc.');
818
+ }
819
+
820
+ function _isSquareJWTPath(path) {
821
+ return path.startsWith('$[\'vc\']');
822
+ }
package/lib/util.js CHANGED
@@ -8,19 +8,29 @@ const TEXT_ENCODER = new TextEncoder();
8
8
  const ENCODED_PERIOD = TEXT_ENCODER.encode('.');
9
9
  const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/;
10
10
 
11
+ export function assert(x, name, type, optional = false) {
12
+ const article = type === 'object' ? 'an' : 'a';
13
+ if(x !== undefined && typeof x !== type) {
14
+ throw new TypeError(
15
+ `${optional ? 'When present, ' : ''} ` +
16
+ `"${name}" must be ${article} ${type}.`);
17
+ }
18
+ }
19
+
20
+ export function assertOptional(x, name, type) {
21
+ return assert(x, name, type, true);
22
+ }
23
+
11
24
  export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
12
25
  try {
13
- if(!(issuerConfigUrl && typeof issuerConfigUrl === 'string')) {
14
- throw new TypeError('"issuerConfigUrl" must be a string.');
15
- }
26
+ assert(issuerConfigUrl, 'issuerConfigUrl', 'string');
16
27
 
17
- const response = await _fetchJSON({url: issuerConfigUrl, agent});
28
+ const response = await fetchJSON({url: issuerConfigUrl, agent});
18
29
  if(!response.data) {
19
30
  const error = new Error('Issuer configuration format is not JSON.');
20
31
  error.name = 'DataError';
21
32
  throw error;
22
33
  }
23
-
24
34
  const {data: issuerMetaData} = response;
25
35
  const {issuer, authorization_server} = issuerMetaData;
26
36
 
@@ -62,7 +72,7 @@ export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
62
72
  // fetch AS meta data
63
73
  const asMetaDataUrl =
64
74
  `${origin}/.well-known/oauth-authorization-server${pathname}`;
65
- const asMetaDataResponse = await _fetchJSON({url: asMetaDataUrl, agent});
75
+ const asMetaDataResponse = await fetchJSON({url: asMetaDataUrl, agent});
66
76
  if(!asMetaDataResponse.data) {
67
77
  const error = new Error('Authorization server meta data is not JSON.');
68
78
  error.name = 'DataError';
@@ -75,11 +85,7 @@ export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
75
85
 
76
86
  // ensure `token_endpoint` is valid
77
87
  const {token_endpoint} = asMetaData;
78
- if(!(token_endpoint && typeof token_endpoint === 'string')) {
79
- const error = new TypeError('"token_endpoint" must be a string.');
80
- error.name = 'DataError';
81
- throw error;
82
- }
88
+ assert(token_endpoint, 'token_endpoint', 'string');
83
89
 
84
90
  // return merged config and separate issuer and AS configs
85
91
  const metadata = {issuer: issuerMetaData, authorizationServer: asMetaData};
@@ -92,6 +98,19 @@ export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
92
98
  }
93
99
  }
94
100
 
101
+ export function fetchJSON({url, agent} = {}) {
102
+ // allow these params to be passed / configured
103
+ const fetchOptions = {
104
+ // max size for issuer config related responses (in bytes, ~4 KiB)
105
+ size: 4096,
106
+ // timeout in ms for fetching an issuer config
107
+ timeout: 5000,
108
+ agent
109
+ };
110
+
111
+ return httpClient.get(url, fetchOptions);
112
+ }
113
+
95
114
  export async function generateDIDProofJWT({
96
115
  signer, nonce, iss, aud, exp, nbf
97
116
  } = {}) {
@@ -126,9 +145,7 @@ export async function generateDIDProofJWT({
126
145
  }
127
146
 
128
147
  export function parseCredentialOfferUrl({url} = {}) {
129
- if(!(url && typeof url === 'string')) {
130
- throw new TypeError('"url" must be a string.');
131
- }
148
+ assert(url, 'url', 'string');
132
149
 
133
150
  /* Parse URL, e.g.:
134
151
 
@@ -194,16 +211,3 @@ function _curveToAlg(crv) {
194
211
  }
195
212
  return crv;
196
213
  }
197
-
198
- function _fetchJSON({url, agent}) {
199
- // allow these params to be passed / configured
200
- const fetchOptions = {
201
- // max size for issuer config related responses (in bytes, ~4 KiB)
202
- size: 4096,
203
- // timeout in ms for fetching an issuer config
204
- timeout: 5000,
205
- agent
206
- };
207
-
208
- return httpClient.get(url, fetchOptions);
209
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalbazaar/oid4-client",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "An OID4 (VC + VP) client",
5
5
  "homepage": "https://github.com/digitalbazaar/oid4-client",
6
6
  "author": {
@@ -24,7 +24,11 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "@digitalbazaar/http-client": "^3.2.0",
27
- "base64url-universal": "^2.0.0"
27
+ "base64url-universal": "^2.0.0",
28
+ "jose": "^4.15.4",
29
+ "jsonpath-plus": "^7.2.0",
30
+ "jsonpointer": "^5.0.1",
31
+ "uuid": "^9.0.1"
28
32
  },
29
33
  "devDependencies": {
30
34
  "c8": "^7.11.3",