@digitalbazaar/oid4-client 4.3.0 → 4.4.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/convert.js ADDED
@@ -0,0 +1,440 @@
1
+ /*!
2
+ * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {
5
+ resolveParams as resolveAuthorizationRequestParams,
6
+ validate as validateAuthorizationRequest
7
+ } from './authorizationRequest.js';
8
+ import {JSONPath} from 'jsonpath-plus';
9
+ import jsonpointer from 'jsonpointer';
10
+
11
+ // converts a VPR to partial "authorization request"
12
+ export function fromVpr({
13
+ verifiablePresentationRequest, strict = false, prefixJwtVcPath
14
+ } = {}) {
15
+ try {
16
+ let {query} = verifiablePresentationRequest;
17
+ if(!Array.isArray(query)) {
18
+ query = [query];
19
+ }
20
+
21
+ // convert any `QueryByExample` queries
22
+ const queryByExample = query.filter(({type}) => type === 'QueryByExample');
23
+ let credentialQuery = [];
24
+ if(queryByExample.length > 0) {
25
+ if(queryByExample.length > 1 && strict) {
26
+ const error = new Error(
27
+ 'Multiple "QueryByExample" VPR queries are not supported.');
28
+ error.name = 'NotSupportedError';
29
+ throw error;
30
+ }
31
+ ([{credentialQuery = []}] = queryByExample);
32
+ if(!Array.isArray(credentialQuery)) {
33
+ credentialQuery = [credentialQuery];
34
+ }
35
+ }
36
+ const authorizationRequest = {
37
+ response_type: 'vp_token',
38
+ presentation_definition: {
39
+ id: crypto.randomUUID(),
40
+ input_descriptors: credentialQuery.map(q => _fromQueryByExampleQuery({
41
+ credentialQuery: q,
42
+ prefixJwtVcPath
43
+ }))
44
+ },
45
+ // default to `direct_post`; caller can override
46
+ response_mode: 'direct_post'
47
+ };
48
+
49
+ // convert any `DIDAuthentication` queries
50
+ const didAuthnQuery = query.filter(
51
+ ({type}) => type === 'DIDAuthentication');
52
+ if(didAuthnQuery.length > 0) {
53
+ if(didAuthnQuery.length > 1 && strict) {
54
+ const error = new Error(
55
+ 'Multiple "DIDAuthentication" VPR queries are not supported.');
56
+ error.name = 'NotSupportedError';
57
+ throw error;
58
+ }
59
+ const [query] = didAuthnQuery;
60
+ const client_metadata = _fromDIDAuthenticationQuery({query, strict});
61
+ authorizationRequest.client_metadata = client_metadata;
62
+ }
63
+
64
+ if(queryByExample.length === 0 && didAuthnQuery.length === 0 && strict) {
65
+ const error = new Error(
66
+ 'Only "DIDAuthentication" and "QueryByExample" VPR queries are ' +
67
+ 'supported.');
68
+ error.name = 'NotSupportedError';
69
+ throw error;
70
+ }
71
+
72
+ // include requested authn params
73
+ if(verifiablePresentationRequest.domain) {
74
+ // since a `domain` was provided, set these defaults:
75
+ authorizationRequest.client_id = verifiablePresentationRequest.domain;
76
+ authorizationRequest.client_id_scheme = 'redirect_uri';
77
+ authorizationRequest.response_uri = authorizationRequest.client_id;
78
+ }
79
+ if(verifiablePresentationRequest.challenge) {
80
+ authorizationRequest.nonce = verifiablePresentationRequest.challenge;
81
+ }
82
+
83
+ return authorizationRequest;
84
+ } catch(cause) {
85
+ const error = new Error(
86
+ 'Could not convert verifiable presentation request to ' +
87
+ 'an OID4VP authorization request.', {cause});
88
+ error.name = 'OperationError';
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ export function pathsToVerifiableCredentialPointers({paths} = {}) {
94
+ // get only the paths inside a verifiable credential
95
+ paths = Array.isArray(paths) ? paths : [paths];
96
+ paths = _getVerifiableCredentialPaths(paths);
97
+ // convert each JSON path to a JSON pointer
98
+ return paths.map(_jsonPathToJsonPointer);
99
+ }
100
+
101
+ // converts an OID4VP authorization request (including its
102
+ // "presentation definition") to a VPR
103
+ export async function toVpr({
104
+ authorizationRequest, strict = false, agent
105
+ } = {}) {
106
+ try {
107
+ // ensure authorization request is valid
108
+ validateAuthorizationRequest({authorizationRequest});
109
+
110
+ // FIXME: in a major release, remove support for params requiring
111
+ // resolution
112
+
113
+ // resolve and validate any additional parameters in the request
114
+ authorizationRequest = await resolveAuthorizationRequestParams({
115
+ authorizationRequest, agent
116
+ });
117
+
118
+ const {
119
+ client_id,
120
+ client_metadata,
121
+ nonce,
122
+ presentation_definition
123
+ } = authorizationRequest;
124
+
125
+ // disallow unsupported `submission_requirements` in strict mode
126
+ if(strict && presentation_definition.submission_requirements) {
127
+ const error = new Error('"submission_requirements" is not supported.');
128
+ error.name = 'NotSupportedError';
129
+ throw error;
130
+ }
131
+
132
+ // generate base VPR from presentation definition
133
+ const verifiablePresentationRequest = {
134
+ // map each `input_descriptors` value to a `QueryByExample` query
135
+ query: [{
136
+ type: 'QueryByExample',
137
+ credentialQuery: presentation_definition.input_descriptors.map(
138
+ inputDescriptor => _toQueryByExampleQuery({inputDescriptor, strict}))
139
+ }]
140
+ };
141
+
142
+ // add `DIDAuthentication` query based on client_metadata
143
+ if(client_metadata) {
144
+ const query = _toDIDAuthenticationQuery({client_metadata, strict});
145
+ if(query !== undefined) {
146
+ verifiablePresentationRequest.query.unshift(query);
147
+ }
148
+ }
149
+
150
+ // map `client_id` to `domain`
151
+ if(client_id !== undefined) {
152
+ verifiablePresentationRequest.domain = client_id;
153
+ }
154
+
155
+ // map `nonce` to `challenge`
156
+ if(nonce !== undefined) {
157
+ verifiablePresentationRequest.challenge = nonce;
158
+ }
159
+
160
+ return {verifiablePresentationRequest};
161
+ } catch(cause) {
162
+ const error = new Error(
163
+ 'Could not convert OID4VP authorization request to ' +
164
+ 'verifiable presentation request.', {cause});
165
+ error.name = 'OperationError';
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ function _filterToValue({filter, strict = false}) {
171
+ /* Each `filter` has a JSON Schema object. In recognition of the fact that
172
+ a query must be usable by common database engines (including perhaps
173
+ encrypted cloud databases) and of the fact that each JSON Schema object will
174
+ come from an untrusted source (and could have malicious regexes, etc.), only
175
+ simple JSON Schema types are supported:
176
+
177
+ `string`: with `const` or `enum`, `format` is not supported and `pattern` has
178
+ partial support as it will be treated as a simple string not a regex; regex
179
+ is a DoS attack vector
180
+
181
+ `array`: with `contains` where uses a `string` filter
182
+
183
+ `allOf`: supported only with the above schemas present in it.
184
+
185
+ */
186
+ let value;
187
+
188
+ const {type} = filter;
189
+ if(type === 'array') {
190
+ if(filter.contains) {
191
+ if(Array.isArray(filter.contains)) {
192
+ return filter.contains.map(filter => _filterToValue({filter, strict}));
193
+ }
194
+ return _filterToValue({filter: filter.contains, strict});
195
+ }
196
+ if(Array.isArray(filter.allOf) && filter.allOf.every(f => f.contains)) {
197
+ return filter.allOf.map(
198
+ f => _filterToValue({filter: f.contains, strict}));
199
+ }
200
+ if(strict) {
201
+ throw new Error(
202
+ 'Unsupported filter; array filters must use "allOf" and/or ' +
203
+ '"contains" with a string filter.');
204
+ }
205
+ return value;
206
+ }
207
+ if(type === 'string' || type === undefined) {
208
+ if(filter.const !== undefined) {
209
+ value = filter.const;
210
+ } else if(filter.pattern) {
211
+ value = filter.pattern;
212
+ } else if(filter.enum) {
213
+ value = filter.enum.slice();
214
+ } else if(strict) {
215
+ throw new Error(
216
+ 'Unsupported filter; string filters must use "const" or "pattern".');
217
+ }
218
+ return value;
219
+ }
220
+ if(strict) {
221
+ throw new Error(`Unsupported filter type "${type}".`);
222
+ }
223
+ }
224
+
225
+ // exported for testing purposes only
226
+ export function _fromQueryByExampleQuery({credentialQuery, prefixJwtVcPath}) {
227
+ // determine `prefixJwtVcPath` default:
228
+ // if `credentialQuery` specifies `acceptedEnvelopes: ['application/jwt']`,
229
+ // then default `prefixJwtVcPath` to `true`
230
+ if(prefixJwtVcPath === undefined &&
231
+ (Array.isArray(credentialQuery.acceptedEnvelopes) &&
232
+ credentialQuery.acceptedEnvelopes.includes?.('application/jwt'))) {
233
+ prefixJwtVcPath = true;
234
+ }
235
+
236
+ const fields = [];
237
+ const inputDescriptor = {
238
+ id: crypto.randomUUID(),
239
+ constraints: {fields}
240
+ };
241
+ if(credentialQuery?.reason) {
242
+ inputDescriptor.purpose = credentialQuery?.reason;
243
+ }
244
+ // FIXME: current implementation only supports top-level string/array
245
+ // properties and presumes strings
246
+ const path = ['$'];
247
+ const {example = {}} = credentialQuery || {};
248
+ for(const key in example) {
249
+ const value = example[key];
250
+ path.push(key);
251
+
252
+ const filter = {};
253
+ if(Array.isArray(value)) {
254
+ filter.type = 'array';
255
+ filter.allOf = value.map(v => ({
256
+ contains: {
257
+ type: 'string',
258
+ const: v
259
+ }
260
+ }));
261
+ } else if(key === 'type') {
262
+ // special provision for array/string for `type`
263
+ filter.type = 'array',
264
+ filter.contains = {
265
+ type: 'string',
266
+ const: value
267
+ };
268
+ } else {
269
+ filter.type = 'string',
270
+ filter.const = value;
271
+ }
272
+ const fieldsPath = [JSONPath.toPathString(path)];
273
+ // include 'vc' path for queries against JWT payloads instead of VCs
274
+ if(prefixJwtVcPath) {
275
+ const vcPath = [...path];
276
+ vcPath.splice(1, 0, 'vc');
277
+ fieldsPath.push(JSONPath.toPathString(vcPath));
278
+ }
279
+ fields.push({
280
+ path: fieldsPath,
281
+ filter
282
+ });
283
+
284
+ path.pop();
285
+ }
286
+
287
+ return inputDescriptor;
288
+ }
289
+
290
+ function _getVerifiableCredentialPaths(paths) {
291
+ // remove any paths that start with what would be present in a
292
+ // presentation submission and adjust any paths that would be part of a
293
+ // JWT-secured VC, such that only actual VC paths remain
294
+ const removed = paths.filter(p => !_isPresentationSubmissionPath(p));
295
+ return [...new Set(removed.map(p => {
296
+ if(_isJWTPath(p)) {
297
+ return '$' + p.slice('$.vc'.length);
298
+ }
299
+ if(_isSquareJWTPath(p)) {
300
+ return '$' + p.slice('$[\'vc\']'.length);
301
+ }
302
+ return p;
303
+ }))];
304
+ }
305
+
306
+ function _isPresentationSubmissionPath(path) {
307
+ return path.startsWith('$.verifiableCredential[') ||
308
+ path.startsWith('$.vp.') ||
309
+ path.startsWith('$[\'verifiableCredential') || path.startsWith('$[\'vp');
310
+ }
311
+
312
+ function _isJWTPath(path) {
313
+ return path.startsWith('$.vc.');
314
+ }
315
+
316
+ function _isSquareJWTPath(path) {
317
+ return path.startsWith('$[\'vc\']');
318
+ }
319
+
320
+ function _jsonPathToJsonPointer(jsonPath) {
321
+ return JSONPath.toPointer(JSONPath.toPathArray(jsonPath));
322
+ }
323
+
324
+ function _fromDIDAuthenticationQuery({query, strict = false}) {
325
+ const cryptosuites = query.acceptedCryptosuites?.map(
326
+ ({cryptosuite}) => cryptosuite);
327
+ if(!(cryptosuites && cryptosuites.length > 0)) {
328
+ if(strict) {
329
+ const error = new Error(
330
+ '"query.acceptedCryptosuites" must be a non-array with specified ' +
331
+ 'cryptosuites to convert from a DIDAuthentication query.');
332
+ error.name = 'NotSupportedError';
333
+ throw error;
334
+ }
335
+ return;
336
+ }
337
+ return {
338
+ require_signed_request_object: false,
339
+ vp_formats: {
340
+ ldp_vp: {
341
+ proof_type: cryptosuites
342
+ }
343
+ }
344
+ };
345
+ }
346
+
347
+ function _toDIDAuthenticationQuery({client_metadata, strict = false}) {
348
+ const {vp_formats} = client_metadata;
349
+ const proofTypes = vp_formats?.ldp_vp?.proof_type;
350
+ if(!Array.isArray(proofTypes)) {
351
+ if(strict) {
352
+ const error = new Error(
353
+ '"client_metadata.vp_formats.ldp_vp.proof_type" must be an array to ' +
354
+ 'convert to DIDAuthentication query.');
355
+ error.name = 'NotSupportedError';
356
+ throw error;
357
+ }
358
+ return;
359
+ }
360
+ return {
361
+ type: 'DIDAuthentication',
362
+ acceptedCryptosuites: proofTypes.map(cryptosuite => ({cryptosuite}))
363
+ };
364
+ }
365
+
366
+ function _toQueryByExampleQuery({inputDescriptor, strict = false}) {
367
+ // every input descriptor must have an `id`
368
+ if(typeof inputDescriptor?.id !== 'string') {
369
+ throw new TypeError('Input descriptor "id" must be a string.');
370
+ }
371
+
372
+ const example = {};
373
+ const credentialQuery = {example};
374
+ if(inputDescriptor.purpose) {
375
+ credentialQuery.reason = inputDescriptor.purpose;
376
+ }
377
+
378
+ /* Note: Each input descriptor object is currently mapped to a single example
379
+ query. If multiple possible path values appear for a single field, these will
380
+ be mapped to multiple properties in the example which may or may not be what
381
+ is intended. This behavior could be changed in a future revision if it
382
+ becomes clear there is a better approach. */
383
+
384
+ const fields = inputDescriptor.constraints?.fields || [];
385
+ for(const field of fields) {
386
+ const {path, filter, optional} = field;
387
+ // skip optional fields
388
+ if(optional === true) {
389
+ continue;
390
+ }
391
+
392
+ try {
393
+ // each field must have a `path` (which can be a string or an array)
394
+ if(!(Array.isArray(path) || typeof path === 'string')) {
395
+ throw new TypeError(
396
+ 'Input descriptor field "path" must be a string or array.');
397
+ }
398
+
399
+ // process any filter
400
+ let value = '';
401
+ if(filter !== undefined) {
402
+ value = _filterToValue({filter, strict});
403
+ }
404
+ // no value understood, skip field
405
+ if(value === undefined) {
406
+ continue;
407
+ }
408
+ // normalize value to array
409
+ if(!Array.isArray(value)) {
410
+ value = [value];
411
+ }
412
+
413
+ // get JSON pointers for every path inside a verifiable credential
414
+ const pointers = pathsToVerifiableCredentialPointers({paths: path});
415
+
416
+ // add values at each path, converting to an array / appending as needed
417
+ for(const pointer of pointers) {
418
+ const existing = jsonpointer.get(example, pointer);
419
+ if(existing === undefined) {
420
+ jsonpointer.set(
421
+ example, pointer, value.length > 1 ? value : value[0]);
422
+ } else if(Array.isArray(existing)) {
423
+ if(!existing.includes(value)) {
424
+ existing.push(...value);
425
+ }
426
+ } else if(existing !== value) {
427
+ jsonpointer.set(example, pointer, [existing, ...value]);
428
+ }
429
+ }
430
+ } catch(cause) {
431
+ const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
432
+ const error = new Error(
433
+ `Could not process input descriptor field: "${id}".`, {cause});
434
+ error.field = field;
435
+ throw error;
436
+ }
437
+ }
438
+
439
+ return credentialQuery;
440
+ }
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
4
  export * as oid4vp from './oid4vp.js';
5
5
  export {
@@ -8,6 +8,7 @@ export {
8
8
  getCredentialOffer,
9
9
  parseCredentialOfferUrl,
10
10
  robustDiscoverIssuer,
11
- signJWT
11
+ signJWT,
12
+ selectJwk
12
13
  } from './util.js';
13
14
  export {OID4Client} from './OID4Client.js';