@digitalbazaar/oid4-client 4.3.0 → 5.0.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,337 @@
1
+ /*!
2
+ * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {createNamedError, selectJwk} from './util.js';
5
+ import {EncryptJWT} from 'jose';
6
+ import {httpClient} from '@digitalbazaar/http-client';
7
+ import jsonpointer from 'jsonpointer';
8
+ import {pathsToVerifiableCredentialPointers} from './convert.js';
9
+
10
+ const TEXT_ENCODER = new TextEncoder();
11
+
12
+ export async function send({
13
+ verifiablePresentation,
14
+ presentationSubmission,
15
+ authorizationRequest,
16
+ vpToken,
17
+ encryptionOptions = {},
18
+ agent
19
+ } = {}) {
20
+ try {
21
+ if(!(verifiablePresentation || vpToken)) {
22
+ throw createNamedError({
23
+ message: 'One of "verifiablePresentation" or "vpToken" must be given.',
24
+ name: 'DataError'
25
+ });
26
+ }
27
+ // if no `vpToken` given, use VP
28
+ vpToken = vpToken ?? JSON.stringify(verifiablePresentation);
29
+
30
+ // if no `presentationSubmission` provided, auto-generate one
31
+ let generatedPresentationSubmission = false;
32
+ if(!presentationSubmission) {
33
+ ({presentationSubmission} = createPresentationSubmission({
34
+ presentationDefinition: authorizationRequest.presentation_definition,
35
+ verifiablePresentation
36
+ }));
37
+ generatedPresentationSubmission = true;
38
+ }
39
+
40
+ // prepare response body
41
+ const body = new URLSearchParams();
42
+
43
+ // if `authorizationRequest.response_mode` is `direct.jwt` generate a JWT
44
+ if(authorizationRequest.response_mode === 'direct_post.jwt') {
45
+ if(submitsFormat({presentationSubmission, format: 'mso_mdoc'}) &&
46
+ !encryptionOptions?.mdl?.sessionTranscript) {
47
+ throw createNamedError({
48
+ message: '"encryptionOptions.mdl.sessionTranscript" is required ' +
49
+ 'when submitting an mDL presentation.',
50
+ name: 'DataError'
51
+ });
52
+ }
53
+
54
+ const jwt = await _encrypt({
55
+ vpToken, presentationSubmission, authorizationRequest,
56
+ encryptionOptions
57
+ });
58
+ body.set('response', jwt);
59
+ } else {
60
+ // include vp token and presentation submittion directly in body
61
+ body.set('vp_token', vpToken);
62
+ body.set(
63
+ 'presentation_submission', JSON.stringify(presentationSubmission));
64
+ }
65
+
66
+ // send response
67
+ const response = await httpClient.post(authorizationRequest.response_uri, {
68
+ agent, body, headers: {accept: 'application/json'},
69
+ // FIXME: limit response size
70
+ // timeout in ms for response
71
+ timeout: 5000
72
+ });
73
+
74
+ // return response data as `result`
75
+ const result = response.data || {};
76
+ if(generatedPresentationSubmission) {
77
+ // return any generated presentation submission
78
+ return {result, presentationSubmission};
79
+ }
80
+ return {result};
81
+ } catch(cause) {
82
+ const message = cause.data?.error_description ?? cause.message;
83
+ const error = new Error(
84
+ `Could not send OID4VP authorization response: ${message}`,
85
+ {cause});
86
+ error.name = 'OperationError';
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ // creates a "presentation submission" from a presentation definition and VP
92
+ export function createPresentationSubmission({
93
+ presentationDefinition, verifiablePresentation
94
+ } = {}) {
95
+ const descriptor_map = [];
96
+ const presentationSubmission = {
97
+ id: crypto.randomUUID(),
98
+ definition_id: presentationDefinition.id,
99
+ descriptor_map
100
+ };
101
+
102
+ try {
103
+ // walk through each input descriptor object and match it to a VC
104
+ let {verifiableCredential: vcs} = verifiablePresentation;
105
+ const single = !Array.isArray(vcs);
106
+ if(single) {
107
+ vcs = [vcs];
108
+ }
109
+ /* Note: It is conceivable that the same VC could match multiple input
110
+ descriptors. In this simplistic implementation, the first VC that matches
111
+ is used. This may result in VCs in the VP not being mapped to an input
112
+ descriptor, but every input descriptor having a VC that matches (i.e., at
113
+ least one VC will be shared across multiple input descriptors). If
114
+ some other behavior is more desirable, this can be changed in a future
115
+ version. */
116
+ for(const inputDescriptor of presentationDefinition.input_descriptors) {
117
+ // walk through each VC and try to match it to the input descriptor
118
+ for(let i = 0; i < vcs.length; ++i) {
119
+ const verifiableCredential = vcs[i];
120
+ if(_matchesInputDescriptor({inputDescriptor, verifiableCredential})) {
121
+ descriptor_map.push({
122
+ id: inputDescriptor.id,
123
+ path: '$',
124
+ format: 'ldp_vp',
125
+ path_nested: {
126
+ format: 'ldp_vc',
127
+ path: single ?
128
+ '$.verifiableCredential' :
129
+ '$.verifiableCredential[' + i + ']'
130
+ }
131
+ });
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ } catch(cause) {
137
+ throw createNamedError({
138
+ message: `Could not create presentation submission: ${cause.message}`,
139
+ name: 'OperationError',
140
+ cause
141
+ });
142
+ }
143
+
144
+ return {presentationSubmission};
145
+ }
146
+
147
+ export function submitsFormat({presentationSubmission, format} = {}) {
148
+ /* e.g. presentation submission submitting an mdoc:
149
+ {
150
+ "definition_id": "mDL-sample-req",
151
+ "id": "mDL-sample-res",
152
+ "descriptor_map": [{
153
+ "id": "org.iso.18013.5.1.mDL",
154
+ "format": "mso_mdoc",
155
+ "path": "$"
156
+ }]
157
+ }
158
+ */
159
+ return presentationSubmission?.descriptor_map?.some(
160
+ e => e?.format === format);
161
+ }
162
+
163
+ async function _encrypt({
164
+ vpToken, presentationSubmission, authorizationRequest, encryptionOptions
165
+ }) {
166
+ // get recipient public JWK from client_metadata JWK key set
167
+ const jwks = authorizationRequest?.client_metadata?.jwks;
168
+ const recipientPublicJwk = selectJwk({
169
+ keys: jwks?.keys, alg: 'ECDH-ES', kty: 'EC', crv: 'P-256', use: 'enc'
170
+ });
171
+ if(!recipientPublicJwk) {
172
+ throw createNamedError({
173
+ message: 'No matching key found for "ECDH-ES" in client meta data ' +
174
+ 'JWK key set.',
175
+ name: 'NotFoundError'
176
+ });
177
+ }
178
+
179
+ // configure `keyManagementParameters` for `EncryptJWT` API
180
+ const keyManagementParameters = {};
181
+ if(encryptionOptions?.mdl?.sessionTranscript) {
182
+ // ISO 18013-7: include specific session transcript params as apu + apv
183
+ const {
184
+ mdocGeneratedNonce,
185
+ // default to using `authorizationRequest.nonce` for verifier nonce
186
+ verifierGeneratedNonce = authorizationRequest.nonce
187
+ } = encryptionOptions.mdl.sessionTranscript;
188
+ // note: `EncryptJWT` API requires `apu/apv` (`partyInfoU`/`partyInfoV`)
189
+ // to be passed as Uint8Arrays; they will be encoded using `base64url` by
190
+ // that API
191
+ keyManagementParameters.apu = TEXT_ENCODER.encode(mdocGeneratedNonce);
192
+ keyManagementParameters.apv = TEXT_ENCODER.encode(verifierGeneratedNonce);
193
+ }
194
+
195
+ const claimSet = {
196
+ vp_token: vpToken,
197
+ presentation_submission: presentationSubmission
198
+ };
199
+ const jwt = await new EncryptJWT(claimSet)
200
+ .setProtectedHeader({
201
+ alg: 'ECDH-ES', enc: 'A256GCM',
202
+ kid: recipientPublicJwk.kid
203
+ })
204
+ .setKeyManagementParameters(keyManagementParameters)
205
+ .encrypt(recipientPublicJwk);
206
+ return jwt;
207
+ }
208
+
209
+ function _filterToValue({filter, strict = false}) {
210
+ /* Each `filter` has a JSON Schema object. In recognition of the fact that
211
+ a query must be usable by common database engines (including perhaps
212
+ encrypted cloud databases) and of the fact that each JSON Schema object will
213
+ come from an untrusted source (and could have malicious regexes, etc.), only
214
+ simple JSON Schema types are supported:
215
+
216
+ `string`: with `const` or `enum`, `format` is not supported and `pattern` has
217
+ partial support as it will be treated as a simple string not a regex; regex
218
+ is a DoS attack vector
219
+
220
+ `array`: with `contains` where uses a `string` filter
221
+
222
+ `allOf`: supported only with the above schemas present in it.
223
+
224
+ */
225
+ let value;
226
+
227
+ const {type} = filter;
228
+ if(type === 'array') {
229
+ if(filter.contains) {
230
+ if(Array.isArray(filter.contains)) {
231
+ return filter.contains.map(filter => _filterToValue({filter, strict}));
232
+ }
233
+ return _filterToValue({filter: filter.contains, strict});
234
+ }
235
+ if(Array.isArray(filter.allOf) && filter.allOf.every(f => f.contains)) {
236
+ return filter.allOf.map(
237
+ f => _filterToValue({filter: f.contains, strict}));
238
+ }
239
+ if(strict) {
240
+ throw new Error(
241
+ 'Unsupported filter; array filters must use "allOf" and/or ' +
242
+ '"contains" with a string filter.');
243
+ }
244
+ return value;
245
+ }
246
+ if(type === 'string' || type === undefined) {
247
+ if(filter.const !== undefined) {
248
+ value = filter.const;
249
+ } else if(filter.pattern) {
250
+ value = filter.pattern;
251
+ } else if(filter.enum) {
252
+ value = filter.enum.slice();
253
+ } else if(strict) {
254
+ throw new Error(
255
+ 'Unsupported filter; string filters must use "const" or "pattern".');
256
+ }
257
+ return value;
258
+ }
259
+ if(strict) {
260
+ throw new Error(`Unsupported filter type "${type}".`);
261
+ }
262
+ }
263
+
264
+ function _matchesInputDescriptor({
265
+ inputDescriptor, verifiableCredential, strict = false
266
+ }) {
267
+ // walk through each field ensuring there is a matching value
268
+ const fields = inputDescriptor?.constraints?.fields || [];
269
+ for(const field of fields) {
270
+ const {path, filter, optional} = field;
271
+ if(optional) {
272
+ // skip field, it is optional
273
+ continue;
274
+ }
275
+
276
+ try {
277
+ // each field must have a `path` (which can be a string or an array)
278
+ if(!(Array.isArray(path) || typeof path === 'string')) {
279
+ throw new Error(
280
+ 'Input descriptor field "path" must be a string or array.');
281
+ }
282
+
283
+ // process any filter
284
+ let value = '';
285
+ if(filter !== undefined) {
286
+ value = _filterToValue({filter, strict});
287
+ }
288
+ // no value to match, presume no match
289
+ if(value === undefined) {
290
+ return false;
291
+ }
292
+ // normalize value to array
293
+ if(!Array.isArray(value)) {
294
+ value = [value];
295
+ }
296
+
297
+ // get JSON pointers for every path inside a verifiable credential
298
+ const pointers = pathsToVerifiableCredentialPointers({paths: path});
299
+
300
+ // check for a value at at least one path
301
+ for(const pointer of pointers) {
302
+ const existing = jsonpointer.get(verifiableCredential, pointer);
303
+ if(existing === undefined) {
304
+ // VC does not match
305
+ return false;
306
+ }
307
+ // look for at least one matching value in `existing`
308
+ let match = false;
309
+ for(const v of value) {
310
+ if(Array.isArray(existing)) {
311
+ if(existing.includes(v)) {
312
+ match = true;
313
+ break;
314
+ }
315
+ } else if(existing === v) {
316
+ match = true;
317
+ break;
318
+ }
319
+ }
320
+ if(!match) {
321
+ return false;
322
+ }
323
+ }
324
+ } catch(cause) {
325
+ const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
326
+ const error = createNamedError({
327
+ message: `Could not process input descriptor field: "${id}".`,
328
+ name: 'DataError',
329
+ cause
330
+ });
331
+ error.field = field;
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ return true;
337
+ }