@digitalbazaar/oid4-client 5.1.0 → 5.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.
@@ -0,0 +1,328 @@
1
+ /*!
2
+ * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {resolvePointer, toJsonPointerMap} from './util.js';
5
+ import {exampleToJsonPointerMap} from './queryByExample.js';
6
+ import {JSONPath} from 'jsonpath-plus';
7
+ import jsonpointer from 'json-pointer';
8
+
9
+ const VALUE_TYPES = new Set(['string', 'number', 'boolean']);
10
+
11
+ export function presentationDefinitionToVprGroups({
12
+ presentation_definition, strict = false
13
+ } = {}) {
14
+ const {input_descriptors: inputDescriptors} = presentation_definition;
15
+
16
+ // only one group (no "OR" with presentation exchange), every input
17
+ // input descriptor converts to a `QueryByExample` query in the same group
18
+ const queries = inputDescriptors.map(inputDescriptor => {
19
+ return {
20
+ type: 'QueryByExample',
21
+ credentialQuery: _toQueryByExampleQuery({inputDescriptor, strict})
22
+ };
23
+ });
24
+
25
+ // output group map with a single group with all `QueryByExample` queries
26
+ const group = new Map([['QueryByExample', queries]]);
27
+ const groupMap = new Map([[undefined, group]]);
28
+ return groupMap;
29
+ }
30
+
31
+ export function inputDescriptorToJsonPointerMap({inputDescriptor} = {}) {
32
+ const queryByExample = _toQueryByExampleQuery({inputDescriptor});
33
+ return exampleToJsonPointerMap(queryByExample);
34
+ }
35
+
36
+ export function vprGroupsToPresentationDefinition({
37
+ groupMap, prefixJwtVcPath
38
+ } = {}) {
39
+ // only a single `QueryByExample` is supported at this time; use last one
40
+ const queryByExample = [...groupMap.values()]
41
+ .filter(g => g.has('QueryByExample'))
42
+ .map(g => g.get('QueryByExample'))
43
+ .at(-1);
44
+ if(!queryByExample) {
45
+ // no presentation definition
46
+ return;
47
+ }
48
+
49
+ const input_descriptors = [];
50
+ const presentationDefinition = {
51
+ id: crypto.randomUUID(),
52
+ input_descriptors
53
+ };
54
+
55
+ // note: same group ID is logical "AND" and different group ID is "OR"
56
+ const groups = [...groupMap.values()];
57
+ for(const queries of groups) {
58
+ // only `QueryByExample` is convertible
59
+ const queryByExamples = queries.get('QueryByExample');
60
+ if(!(queryByExamples?.length > 0)) {
61
+ continue;
62
+ }
63
+
64
+ // for each `QueryByExample`, add another input descriptor (for every
65
+ // "credentialQuery" within it
66
+ for(const queryByExample of queryByExamples) {
67
+ // should only be one `credentialQuery` but handle each one as a new
68
+ // set of input descriptors
69
+ const all = Array.isArray(queryByExample.credentialQuery) ?
70
+ queryByExample.credentialQuery : [queryByExample.credentialQuery];
71
+ for(const credentialQuery of all) {
72
+ input_descriptors.push(_fromQueryByExampleQuery({
73
+ credentialQuery, prefixJwtVcPath
74
+ }));
75
+ }
76
+ }
77
+ }
78
+
79
+ return presentationDefinition;
80
+ }
81
+
82
+ // exported for backwards compatibility only
83
+ export function pathsToVerifiableCredentialPointers({paths} = {}) {
84
+ // get only the paths inside a verifiable credential
85
+ paths = Array.isArray(paths) ? paths : [paths];
86
+ paths = _getVerifiableCredentialPaths(paths);
87
+ // convert each JSON path to a JSON pointer
88
+ return paths.map(_jsonPathToJsonPointer);
89
+ }
90
+
91
+ function _filterToValue({filter, strict = false}) {
92
+ /* Each `filter` has a JSON Schema object. In recognition of the fact that
93
+ a query must be usable by common database engines (including perhaps
94
+ encrypted cloud databases) and of the fact that each JSON Schema object will
95
+ come from an untrusted source (and could have malicious regexes, etc.), only
96
+ simple JSON Schema types are supported:
97
+
98
+ simple type filters (`string`/`number`/`boolean`/`object`): with `const` or
99
+ `enum`, `format` is not supported and `pattern` has partial support as it
100
+ will be treated as a simple string not a regex; regex is a DoS attack
101
+ vector
102
+
103
+ `array`: with `contains` where uses a simple type filter
104
+
105
+ `allOf`: supported only with the above schemas present in it.
106
+
107
+ */
108
+ let value;
109
+
110
+ const {type} = filter;
111
+ if(type === 'array') {
112
+ if(filter.contains) {
113
+ if(Array.isArray(filter.contains)) {
114
+ return filter.contains.map(filter => _filterToValue({filter, strict}));
115
+ }
116
+ return _filterToValue({filter: filter.contains, strict});
117
+ }
118
+ if(Array.isArray(filter.allOf) && filter.allOf.every(f => f.contains)) {
119
+ return filter.allOf.map(
120
+ f => _filterToValue({filter: f.contains, strict}));
121
+ }
122
+ if(strict) {
123
+ throw new Error(
124
+ 'Unsupported filter; array filters must use "allOf" and/or ' +
125
+ '"contains" with a simple type filter.');
126
+ }
127
+ return value;
128
+ }
129
+ if(VALUE_TYPES.has(type) || type === 'object' || type === undefined) {
130
+ if(filter.const !== undefined) {
131
+ value = filter.const;
132
+ } else if(filter.pattern) {
133
+ value = filter.pattern;
134
+ } else if(filter.enum) {
135
+ value = filter.enum.slice();
136
+ } else if(filter.type === 'object') {
137
+ value = {};
138
+ } else if(strict && type === 'string') {
139
+ throw new Error(
140
+ 'Unsupported filter; string filters must use "const" or "pattern".');
141
+ }
142
+ return value;
143
+ }
144
+ if(strict) {
145
+ throw new Error(`Unsupported filter type "${type}".`);
146
+ }
147
+ }
148
+
149
+ // exported for testing purposes only
150
+ export function _fromQueryByExampleQuery({credentialQuery, prefixJwtVcPath}) {
151
+ // determine `prefixJwtVcPath` default:
152
+ // if `credentialQuery` specifies `acceptedEnvelopes: ['application/jwt']`,
153
+ // then default `prefixJwtVcPath` to `true`
154
+ if(prefixJwtVcPath === undefined &&
155
+ (Array.isArray(credentialQuery.acceptedEnvelopes) &&
156
+ credentialQuery.acceptedEnvelopes.includes?.('application/jwt'))) {
157
+ prefixJwtVcPath = true;
158
+ }
159
+
160
+ const fields = [];
161
+ const inputDescriptor = {
162
+ id: crypto.randomUUID(),
163
+ constraints: {fields}
164
+ };
165
+ if(credentialQuery?.reason) {
166
+ inputDescriptor.purpose = credentialQuery?.reason;
167
+ }
168
+
169
+ // convert `example` into json pointers
170
+ const {example = {}} = credentialQuery || {};
171
+ const pointers = toJsonPointerMap({obj: example});
172
+
173
+ // walk pointers and produce fields
174
+ for(const [pointer, value] of pointers) {
175
+ const path = jsonpointer.parse(pointer);
176
+
177
+ const field = {
178
+ path: [
179
+ JSONPath.toPathString(['$', ...path])
180
+ ]
181
+ };
182
+ // include 'vc' path for queries against JWT payloads instead of VCs
183
+ if(prefixJwtVcPath) {
184
+ field.path.push(JSONPath.toPathString(['$', 'vc', ...path]));
185
+ }
186
+
187
+ if(value instanceof Set) {
188
+ field.filter = {
189
+ type: 'array',
190
+ allOf: [...value].map(value => ({
191
+ contains: _primitiveValueToFilter(value)
192
+ }))
193
+ };
194
+ } else {
195
+ field.filter = _primitiveValueToFilter(value);
196
+ }
197
+
198
+ fields.push(field);
199
+ }
200
+
201
+ return inputDescriptor;
202
+ }
203
+
204
+ function _getVerifiableCredentialPaths(paths) {
205
+ // remove any paths that start with what would be present in a
206
+ // presentation submission and adjust any paths that would be part of a
207
+ // JWT-secured VC, such that only actual VC paths remain
208
+ const removed = paths.filter(p => !_isPresentationSubmissionPath(p));
209
+ return [...new Set(removed.map(p => {
210
+ if(_isJWTPath(p)) {
211
+ return '$' + p.slice('$.vc'.length);
212
+ }
213
+ if(_isSquareJWTPath(p)) {
214
+ return '$' + p.slice('$[\'vc\']'.length);
215
+ }
216
+ return p;
217
+ }))];
218
+ }
219
+
220
+ function _isPresentationSubmissionPath(path) {
221
+ return path.startsWith('$.verifiableCredential[') ||
222
+ path.startsWith('$.vp.') ||
223
+ path.startsWith('$[\'verifiableCredential') || path.startsWith('$[\'vp');
224
+ }
225
+
226
+ function _isJWTPath(path) {
227
+ return path.startsWith('$.vc.');
228
+ }
229
+
230
+ function _isSquareJWTPath(path) {
231
+ return path.startsWith('$[\'vc\']');
232
+ }
233
+
234
+ function _jsonPathToJsonPointer(jsonPath) {
235
+ return JSONPath.toPointer(JSONPath.toPathArray(jsonPath));
236
+ }
237
+
238
+ function _toQueryByExampleQuery({inputDescriptor, strict = false}) {
239
+ // every input descriptor must have an `id`
240
+ if(typeof inputDescriptor?.id !== 'string') {
241
+ throw new TypeError('Input descriptor "id" must be a string.');
242
+ }
243
+
244
+ const example = {};
245
+ const credentialQuery = {example};
246
+ if(inputDescriptor.purpose) {
247
+ credentialQuery.reason = inputDescriptor.purpose;
248
+ }
249
+
250
+ /* Note: Each input descriptor object is currently mapped to a single example
251
+ query. If multiple possible path values appear for a single field, these will
252
+ be mapped to multiple properties in the example which may or may not be what
253
+ is intended. This behavior could be changed in a future revision if it
254
+ becomes clear there is a better approach. */
255
+
256
+ const fields = inputDescriptor.constraints?.fields || [];
257
+ for(const field of fields) {
258
+ const {path, filter, optional} = field;
259
+ // skip optional fields
260
+ if(optional === true) {
261
+ continue;
262
+ }
263
+
264
+ try {
265
+ // each field must have a `path` (which can be a string or an array)
266
+ if(!(Array.isArray(path) || typeof path === 'string')) {
267
+ throw new TypeError(
268
+ 'Input descriptor field "path" must be a string or array.');
269
+ }
270
+
271
+ // process any filter
272
+ let value = '';
273
+ if(filter !== undefined) {
274
+ value = _filterToValue({filter, strict});
275
+ }
276
+ // no value understood, skip field
277
+ if(value === undefined) {
278
+ continue;
279
+ }
280
+ // normalize value to array
281
+ const originalValue = value;
282
+ if(!Array.isArray(value)) {
283
+ value = [value];
284
+ }
285
+
286
+ // get JSON pointers for every path inside a verifiable credential
287
+ const pointers = pathsToVerifiableCredentialPointers({paths: path});
288
+
289
+ // add values at each path, converting to an array / appending as needed
290
+ for(const pointer of pointers) {
291
+ const existing = resolvePointer(example, pointer);
292
+ if(existing === undefined) {
293
+ jsonpointer.set(example, pointer, originalValue);
294
+ continue;
295
+ }
296
+
297
+ if(Array.isArray(existing)) {
298
+ if(!existing.includes(value)) {
299
+ existing.push(...value);
300
+ }
301
+ } else if(existing !== value) {
302
+ jsonpointer.set(example, pointer, [existing, ...value]);
303
+ }
304
+ }
305
+ } catch(cause) {
306
+ const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
307
+ const error = new Error(
308
+ `Could not process input descriptor field: "${id}".`, {cause});
309
+ error.field = field;
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ return credentialQuery;
315
+ }
316
+
317
+ function _primitiveValueToFilter(value) {
318
+ const filter = {
319
+ type: typeof value
320
+ };
321
+ if(VALUE_TYPES.has(filter.type)) {
322
+ filter.const = value;
323
+ } else {
324
+ // default to `object`
325
+ filter.type = 'object';
326
+ }
327
+ return filter;
328
+ }
@@ -0,0 +1,8 @@
1
+ /*!
2
+ * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {toJsonPointerMap} from './util.js';
5
+
6
+ export function exampleToJsonPointerMap({example} = {}) {
7
+ return toJsonPointerMap({obj: example, flat: false});
8
+ }
@@ -0,0 +1,105 @@
1
+ /*!
2
+ * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {assert} from '../util.js';
5
+ import jsonpointer from 'json-pointer';
6
+
7
+ export function fromJsonPointerMap({map} = {}) {
8
+ assert(map, 'map', Map);
9
+ return _fromPointers({map});
10
+ }
11
+
12
+ export function isNumber(x) {
13
+ return typeof toNumberIfNumber(x) === 'number';
14
+ }
15
+
16
+ export function isObject(x) {
17
+ return x && typeof x === 'object' && !Array.isArray(x);
18
+ }
19
+
20
+ export function resolvePointer(obj, pointer) {
21
+ if(pointer === '/') {
22
+ return obj;
23
+ }
24
+ try {
25
+ return jsonpointer.get(obj, pointer);
26
+ } catch(e) {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ // produces a map of deep pointers to primitives and sets; the values in each
32
+ // set share the same pointer value and if any value in the set is an object,
33
+ // it becomes a new map of deep pointers from that starting place; the pointer
34
+ // value for an empty objects will be an empty map
35
+ export function toJsonPointerMap({obj, flat = false} = {}) {
36
+ assert(obj, 'obj', 'object');
37
+ return _toPointers({cursor: obj, map: new Map(), flat});
38
+ }
39
+
40
+ export function toNumberIfNumber(x) {
41
+ if(typeof x === 'number') {
42
+ return x;
43
+ }
44
+ const num = parseInt(x, 10);
45
+ if(!isNaN(num)) {
46
+ return num;
47
+ }
48
+ return x;
49
+ }
50
+
51
+ export function _fromPointers({map} = {}) {
52
+ const result = {};
53
+
54
+ for(const [pointer, value] of map) {
55
+ // convert any non-primitive values
56
+ let val = value;
57
+ if(value instanceof Map) {
58
+ val = _fromPointers({map: value});
59
+ } else if(value instanceof Set) {
60
+ val = [...value].map(e => e instanceof Map ? _fromPointers({map: e}) : e);
61
+ }
62
+
63
+ // if root pointer is used, `value` is result
64
+ if(pointer === '/') {
65
+ return val;
66
+ }
67
+
68
+ jsonpointer.set(result, pointer, val);
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ function _toPointers({
75
+ cursor, map, tokens = [], pointer = '/', flat = false
76
+ }) {
77
+ if(!flat && Array.isArray(cursor)) {
78
+ const set = new Set();
79
+ // when `map` is not set, case is array of arrays; return a new map
80
+ const result = map ? set : (map = new Map());
81
+ map.set(pointer, set);
82
+ for(const element of cursor) {
83
+ // reset map, tokens, and pointer for array elements
84
+ set.add(_toPointers({cursor: element, flat}));
85
+ }
86
+ return result;
87
+ }
88
+ if(cursor !== null && typeof cursor === 'object') {
89
+ map = map ?? new Map();
90
+ const entries = Object.entries(cursor);
91
+ if(entries.length === 0) {
92
+ // ensure empty object / array case is represented
93
+ map.set(pointer, Array.isArray(cursor) ? new Set() : new Map());
94
+ }
95
+ for(const [token, value] of entries) {
96
+ tokens.push(String(token));
97
+ pointer = jsonpointer.compile(tokens);
98
+ _toPointers({cursor: value, map, tokens, pointer, flat});
99
+ tokens.pop();
100
+ }
101
+ return map;
102
+ }
103
+ map?.set(pointer, cursor);
104
+ return cursor;
105
+ }