@digitalbazaar/oid4-client 5.9.1 → 5.10.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/OID4Client.js CHANGED
@@ -1,7 +1,10 @@
1
1
  /*!
2
- * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2022-2026 Digital Bazaar, Inc.
3
3
  */
4
- import {createCredentialRequestsFromOffer} from './oid4vci/credentialOffer.js';
4
+ import {
5
+ createAuthorizationDetailsFromOffer, createCredentialRequestsFromOffer
6
+ } from './oid4vci/credentialOffer.js';
7
+ import {createNamedError} from './util.js';
5
8
  import {generateDIDProofJWT} from './oid4vci/proofs.js';
6
9
  import {httpClient} from '@digitalbazaar/http-client';
7
10
  import {robustDiscoverIssuer} from './oid4vci/discovery.js';
@@ -12,12 +15,23 @@ const GRANT_TYPES = new Map([
12
15
  const HEADERS = {accept: 'application/json'};
13
16
 
14
17
  export class OID4Client {
15
- constructor({accessToken = null, agent, issuerConfig, metadata, offer} = {}) {
18
+ constructor({
19
+ accessToken = null, agent, authorizationDetails,
20
+ issuerConfig, metadata, offer,
21
+ // default to "compatible" versions where the client will attempt to
22
+ // detect and work with whatever version the server supports, for the
23
+ // versions presently supported by this library
24
+ oid4vciVersion = 'detect',
25
+ oid4vpVersion = 'detect'
26
+ } = {}) {
16
27
  this.accessToken = accessToken;
17
28
  this.agent = agent;
29
+ this.authorizationDetails = authorizationDetails;
18
30
  this.metadata = metadata;
19
31
  this.issuerConfig = issuerConfig;
20
32
  this.offer = offer;
33
+ this.oid4vciVersion = oid4vciVersion;
34
+ this.oid4vpVersion = oid4vpVersion;
21
35
  }
22
36
 
23
37
  async getNonce({agent, headers = HEADERS} = {}) {
@@ -26,57 +40,59 @@ export class OID4Client {
26
40
  // get nonce endpoint
27
41
  const {nonce_endpoint: url} = this.issuerConfig;
28
42
  if(url === undefined) {
29
- const error = new Error('Credential issuer has no "nonce_endpoint".');
30
- error.name = 'DataError';
31
- throw error;
43
+ throw createNamedError({
44
+ message: 'Credential issuer has no "nonce_endpoint".',
45
+ name: 'DataError'
46
+ });
32
47
  }
33
48
  if(!url?.startsWith('https://')) {
34
- const error = new Error(
35
- `Nonce endpoint "${url}" does not start with "https://".`);
36
- error.name = 'DataError';
37
- throw error;
49
+ throw createNamedError({
50
+ message: `Nonce endpoint "${url}" does not start with "https://".`,
51
+ name: 'DataError'
52
+ });
38
53
  }
39
54
 
40
55
  // get nonce
41
56
  response = await httpClient.post(url, {agent, headers});
42
57
  if(!response.data) {
43
- const error = new Error('Nonce response format is not JSON.');
44
- error.name = 'DataError';
45
- throw error;
58
+ throw createNamedError({
59
+ message: 'Nonce response format is not JSON.',
60
+ name: 'DataError'
61
+ });
46
62
  }
47
63
  if(response.data.c_nonce === undefined) {
48
- const error = new Error('Nonce not provided in response.');
49
- error.name = 'DataError';
50
- throw error;
64
+ throw createNamedError({
65
+ message: 'Nonce not provided in response.',
66
+ name: 'DataError'
67
+ });
51
68
  }
52
69
  } catch(cause) {
53
- const error = new Error('Could not get nonce.', {cause});
54
- error.name = 'DataError';
55
- throw error;
70
+ throw createNamedError({
71
+ message: 'Could not get nonce.',
72
+ name: 'DataError',
73
+ cause
74
+ });
56
75
  }
57
76
 
58
77
  const {c_nonce: nonce} = response.data;
59
78
  return {nonce, response};
60
79
  }
61
80
 
81
+ // deprecated; always call `requestCredentials()` instead
62
82
  async requestCredential({
63
83
  credentialDefinition, did, didProofSigner, nonce, agent, format = 'ldp_vc'
64
84
  } = {}) {
65
- const {issuerConfig, offer} = this;
85
+ const {authorizationDetails, issuerConfig, offer, oid4vciVersion} = this;
66
86
  let requests;
67
87
  if(credentialDefinition === undefined) {
68
88
  if(!offer) {
69
- throw new TypeError('"credentialDefinition" must be an object.');
89
+ throw new TypeError('"offer" must be an object.');
70
90
  }
71
91
  requests = createCredentialRequestsFromOffer({
72
- issuerConfig, offer, format
92
+ issuerConfig, offer, format, authorizationDetails, oid4vciVersion
73
93
  });
74
- if(requests.length > 1) {
75
- throw new Error(
76
- 'More than one credential is offered; ' +
77
- 'use "requestCredentials()" instead.');
78
- }
79
94
  } else {
95
+ // OID4VCI Draft 13 only
80
96
  requests = [{
81
97
  format,
82
98
  credential_definition: credentialDefinition
@@ -93,292 +109,124 @@ export class OID4Client {
93
109
  } = {}) {
94
110
  // if `nonce` is given, then `did` and `didProofSigner` must also be
95
111
  if(nonce !== undefined && !(did && didProofSigner)) {
96
- throw new Error(
97
- 'If "nonce" is given then "did" and "didProofSigner" are required.');
112
+ throw createNamedError({
113
+ message:
114
+ 'If "nonce" is given then "did" and "didProofSigner" are required.',
115
+ name: 'DataError'
116
+ });
98
117
  }
99
118
 
100
- const {issuerConfig, offer} = this;
119
+ const {authorizationDetails, issuerConfig, offer, oid4vciVersion} = this;
101
120
  if(requests === undefined && offer) {
102
121
  requests = createCredentialRequestsFromOffer({
103
- issuerConfig, offer, format
122
+ issuerConfig, offer, format, authorizationDetails, oid4vciVersion
104
123
  });
105
124
  } else if(!(Array.isArray(requests) && requests.length > 0)) {
106
125
  throw new TypeError('"requests" must be an array of length >= 1.');
107
126
  }
108
127
  requests.forEach(_assertRequest);
109
- // set default `format`
110
- requests = requests.map(r => ({format, ...r}));
111
128
 
112
- try {
113
- /* First send credential request(s) to DS without DID proof JWT (unless
114
- `nonce` is given) e.g.:
115
-
116
- POST /credential HTTP/1.1
117
- Host: server.example.com
118
- Content-Type: application/json
119
- Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
120
-
121
- {
122
- "format": "ldp_vc",
123
- "credential_definition": {...},
124
- // only present on retry after server requests it or if nonce is given
125
- "proof": {
126
- "proof_type": "jwt",
127
- "jwt": "eyJraW..."
128
- }
129
- }
130
-
131
- OR (if multiple `requests` were given)
132
-
133
- POST /batch_credential HTTP/1.1
134
- Host: server.example.com
135
- Content-Type: application/json
136
- Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
137
-
138
- {
139
- "credential_requests": [{
140
- "format": "ldp_vc",
141
- "credential_definition": {...},
142
- // only present on retry after server requests it
143
- "proof": {
144
- "proof_type": "jwt",
145
- "jwt": "eyJraW..."
146
- }
147
- }, {
148
- ...
149
- }]
150
- }
151
- */
152
- let url;
153
- let json;
154
- if(requests.length > 1 || alwaysUseBatchEndpoint) {
155
- ({batch_credential_endpoint: url} = this.issuerConfig);
156
- json = {credential_requests: requests};
157
- } else {
158
- ({credential_endpoint: url} = this.issuerConfig);
159
- json = {...requests[0]};
160
- }
129
+ // determine if OID4VCI 1.0+ is to be used
130
+ const version1Plus = requests.some(r => r.credential_identifier);
161
131
 
162
- if(nonce !== undefined) {
163
- // add DID proof JWT to json
164
- await _addDIDProofJWT({issuerConfig, json, nonce, did, didProofSigner});
165
- }
132
+ if(!version1Plus) {
133
+ // set default `format` for requests with `credential_definition`
134
+ // (OID4VCI Draft 13 only)
135
+ requests = requests.map(
136
+ r => r.credential_definition ? {format, ...r} : r);
137
+ }
166
138
 
139
+ try {
167
140
  let result;
168
- const headers = {
169
- ...HEADERS,
170
- authorization: `Bearer ${this.accessToken}`
171
- };
172
- for(let retries = 0; retries <= 1; ++retries) {
173
- try {
174
- const response = await httpClient.post(url, {agent, headers, json});
175
- result = response.data;
176
- if(!result) {
177
- const error = new Error('Credential response format is not JSON.');
178
- error.name = 'DataError';
179
- throw error;
180
- }
181
- break;
182
- } catch(cause) {
183
- // presentation is required to continue issuance
184
- if(_isPresentationRequired(cause)) {
185
- const {data: details} = cause;
186
- const error = new Error('Presentation is required.', {cause});
187
- error.name = 'NotAllowedError';
188
- error.details = details;
189
- throw error;
190
- }
191
-
192
- if(!_isMissingProofError(cause)) {
193
- // other non-specific error case
194
- throw cause;
195
- }
196
-
197
- // if `didProofSigner` is not provided, throw error
198
- if(!(did && didProofSigner)) {
199
- const {data: details} = cause;
200
- const error = new Error('DID authentication is required.', {cause});
201
- error.name = 'NotAllowedError';
202
- error.details = details;
203
- throw error;
204
- }
205
-
206
- // validate that `result` has
207
- let {data: {c_nonce: nonce}} = cause;
208
- if(!(nonce && typeof nonce === 'string')) {
209
- // try to get a nonce
210
- ({nonce} = await this.getNonce({agent}));
211
- }
212
-
213
- // add DID proof JWT to json
214
- await _addDIDProofJWT({
215
- issuerConfig, json, nonce, did, didProofSigner
141
+ const {accessToken} = this;
142
+ if(version1Plus) {
143
+ // OID4VCI 1.0+ ... make N-many requests in parallel, with each one
144
+ // adding a DID proof, if requested and N-many nonces might be required
145
+ // FIXME: use p-queue to manage work
146
+ const {credential_endpoint: url} = issuerConfig;
147
+ const results = await Promise.all(requests.map(async request => {
148
+ const json = {...request};
149
+ return _requestCredential({
150
+ accessToken, issuerConfig,
151
+ url, json, nonce, did, didProofSigner, agent
216
152
  });
153
+ }));
154
+ // for backwards compatibility, return all results independently,
155
+ // combined, and singular (if applicable)
156
+ const credentials = results
157
+ .map(r => r?.credentials?.map(e => e.credential))
158
+ .flat();
159
+ result = {credential_responses: results, credentials};
160
+ // backwards compatibility with common draft 13 credential calls
161
+ if(credentials.length === 1) {
162
+ result.credential = credentials[0];
217
163
  }
164
+ if(credentials.every(c => c?.['@context'])) {
165
+ result.format = 'ldp_vc';
166
+ }
167
+ } else {
168
+ // draft 13...
169
+ let url;
170
+ let json;
171
+ if(requests.length > 1 || alwaysUseBatchEndpoint) {
172
+ ({batch_credential_endpoint: url} = issuerConfig);
173
+ json = {credential_requests: requests};
174
+ } else {
175
+ ({credential_endpoint: url} = issuerConfig);
176
+ json = {...requests[0]};
177
+ }
178
+ result = await _requestCredential({
179
+ accessToken, issuerConfig,
180
+ url, json, nonce, did, didProofSigner, agent
181
+ });
218
182
  }
219
-
220
- // wallet / client receives credential(s):
221
- /* Note: The credential is not wrapped here in a VP in the current spec:
222
-
223
- HTTP/1.1 200 OK
224
- Content-Type: application/json
225
- Cache-Control: no-store
226
-
227
- {
228
- "format": "ldp_vc",
229
- "credential" : {...}
230
- }
231
-
232
- OR (if multiple VCs *of the same type* were issued)
233
-
234
- {
235
- "format": "ldp_vc",
236
- "credentials" : {...}
237
- }
238
-
239
- OR (if multiple `requests` were given)
240
-
241
- {
242
- "credential_responses": [{
243
- "format": "ldp_vc",
244
- "credential": {...}
245
- }]
246
- }
247
- */
248
183
  return result;
249
184
  } catch(cause) {
250
- const error = new Error('Could not receive credentials.', {cause});
251
- error.name = 'OperationError';
252
- throw error;
185
+ throw createNamedError({
186
+ message: 'Could not receive credentials.',
187
+ name: 'OperationError',
188
+ cause
189
+ });
253
190
  }
254
191
  }
255
192
 
256
193
  // create a client from a credential offer
257
- static async fromCredentialOffer({offer, agent} = {}) {
258
- // validate offer
259
- const {
260
- credential_issuer,
261
- credentials,
262
- credential_configuration_ids,
263
- grants = {}
264
- } = offer;
265
- let parsedIssuer;
266
- try {
267
- parsedIssuer = new URL(credential_issuer);
268
- if(parsedIssuer.protocol !== 'https:') {
269
- throw new Error('Only "https" credential issuer URLs are supported.');
270
- }
271
- } catch(cause) {
272
- throw new Error('"offer.credential_issuer" is not valid.', {cause});
273
- }
274
- if(credentials === undefined &&
275
- credential_configuration_ids === undefined) {
276
- throw new Error(
277
- 'Either "offer.credential_configuration_ids" or ' +
278
- '"offer.credentials" is required.');
279
- }
280
- if(credential_configuration_ids !== undefined &&
281
- !Array.isArray(credential_configuration_ids)) {
282
- throw new Error('"offer.credential_configuration_ids" is not valid.');
283
- }
284
- if(credentials !== undefined &&
285
- !(Array.isArray(credentials) && credentials.length > 0 &&
286
- credentials.every(c => c && (
287
- typeof c === 'object' || typeof c === 'string')))) {
288
- throw new Error('"offer.credentials" is not valid.');
289
- }
290
- const grant = grants[GRANT_TYPES.get('preAuthorizedCode')];
291
- if(!grant) {
292
- // FIXME: implement `authorization_code` grant type as well
293
- throw new Error('Only "pre-authorized_code" grant type is implemented.');
294
- }
295
- const {
296
- 'pre-authorized_code': preAuthorizedCode,
297
- // FIXME: update to `tx_code` terminology
298
- user_pin_required: userPinRequired
299
- } = grant;
300
- if(!preAuthorizedCode) {
301
- throw new Error('"offer.grant" is missing "pre-authorized_code".');
302
- }
303
- if(userPinRequired) {
304
- throw new Error('User pin is not implemented.');
305
- }
194
+ static async fromCredentialOffer({
195
+ offer, supportedFormats = ['ldp_vc'],
196
+ oid4vciVersion = 'detect', oid4vpVersion = 'detect',
197
+ agent
198
+ } = {}) {
199
+ // parse offer
200
+ const {issuer, preAuthorizedCode} = _parseOffer({offer});
306
201
 
307
202
  try {
308
203
  // discover issuer info
309
204
  const {issuerConfig, metadata} = await robustDiscoverIssuer({
310
- issuer: credential_issuer, agent
205
+ issuer, agent
311
206
  });
312
207
 
313
- /* First get access token from AS (Authorization Server), e.g.:
314
-
315
- POST /token HTTP/1.1
316
- Host: server.example.com
317
- Content-Type: application/x-www-form-urlencoded
318
- grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
319
- &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
320
- &user_pin=493536
321
-
322
- Note a bad response would look like:
323
-
324
- /*
325
- HTTP/1.1 400 Bad Request
326
- Content-Type: application/json
327
- Cache-Control: no-store
328
- {
329
- "error": "invalid_request"
330
- }
331
- */
332
- const body = new URLSearchParams();
333
- body.set('grant_type', GRANT_TYPES.get('preAuthorizedCode'));
334
- body.set('pre-authorized_code', preAuthorizedCode);
335
- const {token_endpoint} = issuerConfig;
336
- const response = await httpClient.post(token_endpoint, {
337
- agent, body, headers: HEADERS
208
+ // get access token from AS (Authorization Server)
209
+ const {accessToken, authorizationDetails} = await _getAccessToken({
210
+ issuerConfig, preAuthorizedCode,
211
+ // request authz details if the server supports it
212
+ authorizationDetails: createAuthorizationDetailsFromOffer({
213
+ issuerConfig, offer, supportedFormats, oid4vciVersion
214
+ }),
215
+ agent
338
216
  });
339
- const {data: result} = response;
340
- if(!result) {
341
- const error = new Error(
342
- 'Could not get access token; response is not JSON.');
343
- error.name = 'DataError';
344
- throw error;
345
- }
346
-
347
- /* Validate response body (Note: Do not check or use `c_nonce*` here
348
- because it conflates AS with DS (Delivery Server)), e.g.:
349
-
350
- HTTP/1.1 200 OK
351
- Content-Type: application/json
352
- Cache-Control: no-store
353
-
354
- {
355
- "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ",
356
- "token_type": "bearer",
357
- "expires_in": 86400
358
- }
359
- */
360
- const {access_token: accessToken, token_type} = result;
361
- if(!(accessToken && typeof accessToken === 'string')) {
362
- const error = new Error(
363
- 'Invalid access token response; "access_token" must be a string.');
364
- error.name = 'DataError';
365
- throw error;
366
- }
367
- if(token_type !== 'bearer') {
368
- const error = new Error(
369
- 'Invalid access token response; "token_type" must be a "bearer".');
370
- error.name = 'DataError';
371
- throw error;
372
- }
373
217
 
374
218
  // create client w/access token
375
219
  return new OID4Client({
376
- accessToken, agent, issuerConfig, metadata, offer
220
+ accessToken, agent, authorizationDetails,
221
+ issuerConfig, metadata, offer,
222
+ oid4vciVersion, oid4vpVersion
377
223
  });
378
224
  } catch(cause) {
379
- const error = new Error('Could not create OID4 client.', {cause});
380
- error.name = 'OperationError';
381
- throw error;
225
+ throw createNamedError({
226
+ message: 'Could not create OID4 client.',
227
+ name: 'OperationError',
228
+ cause
229
+ });
382
230
  }
383
231
  }
384
232
  }
@@ -400,33 +248,289 @@ async function _addDIDProofJWT({
400
248
  // add proof to body to be posted and loop to retry
401
249
  const proof = {proof_type: 'jwt', jwt};
402
250
  if(json.credential_requests) {
251
+ // OID4VCI Draft 13 only
403
252
  json.credential_requests = json.credential_requests.map(
404
253
  cr => ({...cr, proof}));
405
- } else {
254
+ } else if(json.credential_definition) {
255
+ // OID4VCI Draft 13 only
406
256
  json.proof = proof;
257
+ } else {
258
+ // OID4VCI 1.0+
259
+ json.proofs = {
260
+ ...proof,
261
+ jwt: [proof.jwt]
262
+ };
263
+ }
264
+ }
265
+
266
+ async function _requestCredential({
267
+ accessToken, issuerConfig, url, json, nonce, did, didProofSigner, agent
268
+ }) {
269
+ /* First send credential request(s) to DS without DID proof JWT (unless
270
+ `nonce` is given) e.g.:
271
+
272
+ POST /credential HTTP/1.1
273
+ Host: server.example.com
274
+ Content-Type: application/json
275
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
276
+
277
+ {
278
+ "format": "ldp_vc",
279
+ "credential_definition": {...},
280
+ // only present on retry after server requests it or if nonce is given
281
+ "proof": {
282
+ "proof_type": "jwt",
283
+ "jwt": "eyJraW..."
284
+ }
285
+ }
286
+ OR
287
+ {
288
+ "credential_identifier": "foo",
289
+ ...
290
+ }
291
+ OR
292
+ {
293
+ "credential_configuration_id": "bar",
294
+ ...
295
+ }
296
+
297
+ OR (if multiple `requests` were given w/ Draft 13)
298
+
299
+ POST /batch_credential HTTP/1.1
300
+ Host: server.example.com
301
+ Content-Type: application/json
302
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
303
+
304
+ {
305
+ "credential_requests": [{
306
+ "format": "ldp_vc",
307
+ "credential_definition": {...},
308
+ // only present on retry after server requests it
309
+ "proof": {
310
+ "proof_type": "jwt",
311
+ "jwt": "eyJraW..."
312
+ }
313
+ }, {
314
+ ...
315
+ }]
316
+ }
317
+
318
+ OR (if multiple `requests` were given w/1.0+), N-many requests will be
319
+ repeated to `/credential`
320
+
321
+ */
322
+ if(nonce !== undefined) {
323
+ // add DID proof JWT to json
324
+ await _addDIDProofJWT({issuerConfig, json, nonce, did, didProofSigner});
325
+ }
326
+
327
+ let result;
328
+ const headers = {
329
+ ...HEADERS,
330
+ authorization: `Bearer ${accessToken}`
331
+ };
332
+ for(let retries = 0; retries <= 1; ++retries) {
333
+ try {
334
+ const response = await httpClient.post(url, {agent, headers, json});
335
+ result = response.data;
336
+ if(!result) {
337
+ throw createNamedError({
338
+ message: 'Credential response format is not JSON.',
339
+ name: 'DataError'
340
+ });
341
+ }
342
+ break;
343
+ } catch(cause) {
344
+ // presentation is required to continue issuance
345
+ if(_isPresentationRequired(cause)) {
346
+ throw createNamedError({
347
+ message: 'Presentation is required.',
348
+ name: 'NotAllowedError',
349
+ cause,
350
+ details: cause.data
351
+ });
352
+ }
353
+
354
+ if(!_isMissingProofError(cause)) {
355
+ // other non-specific error case
356
+ throw cause;
357
+ }
358
+
359
+ // if `didProofSigner` is not provided, throw error
360
+ if(!(did && didProofSigner)) {
361
+ throw createNamedError({
362
+ message: 'DID authentication is required.',
363
+ name: 'NotAllowedError',
364
+ cause,
365
+ details: cause.data
366
+ });
367
+ }
368
+
369
+ // validate that `result` has a nonce
370
+ let {data: {c_nonce: nonce}} = cause;
371
+ if(!(nonce && typeof nonce === 'string')) {
372
+ // try to get a nonce
373
+ ({nonce} = await this.getNonce({agent}));
374
+ }
375
+
376
+ // add DID proof JWT to json
377
+ await _addDIDProofJWT({
378
+ issuerConfig, json, nonce, did, didProofSigner
379
+ });
380
+ }
381
+ }
382
+
383
+ // wallet / client receives credential(s):
384
+ /* Note: The credential is not wrapped here in a VP in the current spec:
385
+
386
+ HTTP/1.1 200 OK
387
+ Content-Type: application/json
388
+ Cache-Control: no-store
389
+
390
+ {
391
+ "format": "ldp_vc",
392
+ "credential" : {...}
393
+ }
394
+
395
+ OR (if multiple VCs *of the same type* were issued)
396
+
397
+ {
398
+ "format": "ldp_vc",
399
+ "credentials" : {...}
407
400
  }
401
+
402
+ OR (if multiple `requests` were given)
403
+
404
+ {
405
+ "credential_responses": [{
406
+ "format": "ldp_vc",
407
+ "credential": {...}
408
+ }]
409
+ }
410
+ */
411
+ return result;
408
412
  }
409
413
 
410
414
  function _assertRequest(request) {
415
+ // all current versions of OID4VCI require `request` to be an object
411
416
  if(!(request && typeof request === 'object')) {
412
417
  throw new TypeError('"request" must be an object.');
413
418
  }
419
+
420
+ // OID4VCI 1.0+ request format
421
+ if(request.credential_configuration_id) {
422
+ if(typeof request.credential_configuration_id !== 'string') {
423
+ throw new TypeError(
424
+ 'Credential request "credential_configuration_id" must be a string.');
425
+ }
426
+ return;
427
+ }
428
+
429
+ // OID4VCI 1.0+ request format
430
+ if(request.credential_identifier) {
431
+ if(typeof request.credential_identifier !== 'string') {
432
+ throw new TypeError(
433
+ 'Credential request "credential_identifier" must be a string.');
434
+ }
435
+ return;
436
+ }
437
+
438
+ // OID4VCI Draft 13 format
414
439
  const {credential_definition} = request;
415
440
  if(!(credential_definition && typeof credential_definition === 'object')) {
416
441
  throw new TypeError(
417
442
  'Credential request "credential_definition" must be an object.');
418
443
  }
419
- const {'@context': context, type: type} = credential_definition;
420
- if(!(Array.isArray(context) && context.length > 0)) {
421
- throw new TypeError(
422
- 'Credential definition "@context" must be an array of length >= 1.');
423
- }
444
+ const {type: type} = credential_definition;
424
445
  if(!(Array.isArray(type) && type.length > 0)) {
425
446
  throw new TypeError(
426
- 'Credential definition "type" must be an array of length >= 2.');
447
+ 'Credential definition "type" must be an array of length > 0.');
427
448
  }
428
449
  }
429
450
 
451
+ async function _getAccessToken({
452
+ issuerConfig, preAuthorizedCode, authorizationDetails, agent
453
+ }) {
454
+ /* First get access token from AS (Authorization Server), e.g.:
455
+
456
+ POST /token HTTP/1.1
457
+ Host: server.example.com
458
+ Content-Type: application/x-www-form-urlencoded
459
+ grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
460
+ &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
461
+ &user_pin=493536
462
+ &authorization_details=<URI-component-encoded JSON array>
463
+
464
+ Note a bad response would look like:
465
+
466
+ /*
467
+ HTTP/1.1 400 Bad Request
468
+ Content-Type: application/json
469
+ Cache-Control: no-store
470
+ {
471
+ "error": "invalid_request"
472
+ }
473
+ */
474
+ const body = new URLSearchParams();
475
+ body.set('grant_type', GRANT_TYPES.get('preAuthorizedCode'));
476
+ body.set('pre-authorized_code', preAuthorizedCode);
477
+ if(authorizationDetails) {
478
+ body.set('authorization_details', JSON.stringify(authorizationDetails));
479
+ }
480
+ const {token_endpoint} = issuerConfig;
481
+ const response = await httpClient.post(token_endpoint, {
482
+ agent, body, headers: HEADERS
483
+ });
484
+ const {data: result} = response;
485
+ if(!result) {
486
+ throw createNamedError({
487
+ message: 'Could not get access token; response is not JSON.',
488
+ name: 'DataError'
489
+ });
490
+ }
491
+
492
+ /* Validate response body (Note: Do not check or use `c_nonce*` here
493
+ because it conflates AS with DS (Delivery Server)), e.g.:
494
+
495
+ HTTP/1.1 200 OK
496
+ Content-Type: application/json
497
+ Cache-Control: no-store
498
+
499
+ {
500
+ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ",
501
+ "token_type": "bearer",
502
+ "expires_in": 86400
503
+ }
504
+ */
505
+ const {access_token: accessToken, token_type} = result;
506
+ ({authorization_details: authorizationDetails} = result);
507
+ if(!(accessToken && typeof accessToken === 'string')) {
508
+ throw createNamedError({
509
+ message:
510
+ 'Invalid access token response; "access_token" must be a string.',
511
+ name: 'DataError'
512
+ });
513
+ }
514
+ if(token_type !== 'bearer') {
515
+ throw createNamedError({
516
+ message:
517
+ 'Invalid access token response; "token_type" must be a "bearer".',
518
+ name: 'DataError'
519
+ });
520
+ }
521
+ if(authorizationDetails !== undefined &&
522
+ !Array.isArray(authorizationDetails)) {
523
+ throw createNamedError({
524
+ message:
525
+ 'Invalid access token response; ' +
526
+ '"authorization_details" must be an array.',
527
+ name: 'DataError'
528
+ });
529
+ }
530
+
531
+ return {accessToken, authorizationDetails};
532
+ }
533
+
430
534
  function _isMissingProofError(error) {
431
535
  /* If DID authn is required, delivery server sends, e.g.:
432
536
 
@@ -466,3 +570,80 @@ function _isPresentationRequired(error) {
466
570
  const errorType = error.data?.error;
467
571
  return error.status === 400 && errorType === 'presentation_required';
468
572
  }
573
+
574
+ function _parseOffer({offer}) {
575
+ // relevant fields to validate
576
+ const {
577
+ credential_issuer,
578
+ credentials,
579
+ credential_configuration_ids,
580
+ grants = {}
581
+ } = offer;
582
+
583
+ // ensure issuer is a valid URL
584
+ let parsedIssuer;
585
+ try {
586
+ parsedIssuer = new URL(credential_issuer);
587
+ if(parsedIssuer.protocol !== 'https:') {
588
+ throw createNamedError({
589
+ message: 'Only "https" credential issuer URLs are supported.',
590
+ name: 'NotSupportedError'
591
+ });
592
+ }
593
+ } catch(cause) {
594
+ throw createNamedError({
595
+ message: '"offer.credential_issuer" is not valid.',
596
+ name: 'DataError',
597
+ cause
598
+ });
599
+ }
600
+
601
+ // OID4VCI Draft 13 used `credentials`
602
+ // 1.0+ uses `credential_configuration_ids`
603
+ if(credentials === undefined && credential_configuration_ids === undefined) {
604
+ throw createNamedError({
605
+ message:
606
+ 'Either "offer.credential_configuration_ids" or ' +
607
+ '"offer.credentials" is required.',
608
+ name: 'DataError'
609
+ });
610
+ }
611
+ if(credential_configuration_ids !== undefined &&
612
+ !Array.isArray(credential_configuration_ids)) {
613
+ throw createNamedError({
614
+ message: '"offer.credential_configuration_ids" is not valid.',
615
+ name: 'DataError'
616
+ });
617
+ }
618
+ if(credentials !== undefined &&
619
+ !(Array.isArray(credentials) && credentials.length > 0 &&
620
+ credentials.every(c => c && (
621
+ typeof c === 'object' || typeof c === 'string')))) {
622
+ throw createNamedError({
623
+ message: '"offer.credentials" is not valid.',
624
+ name: 'DataError'
625
+ });
626
+ }
627
+
628
+ // validate grant
629
+ const grant = grants?.[GRANT_TYPES.get('preAuthorizedCode')];
630
+ if(!grant) {
631
+ throw createNamedError({
632
+ message: 'Only "pre-authorized_code" grant type is supported.',
633
+ name: 'NotSupportedError'
634
+ });
635
+ }
636
+ const {
637
+ 'pre-authorized_code': preAuthorizedCode
638
+ // note: `tx_code` is presently ignored/not supported; if required an
639
+ // error will be thrown by the appropriate software
640
+ } = grant;
641
+ if(!preAuthorizedCode) {
642
+ throw createNamedError({
643
+ message: '"offer.grant" is missing "pre-authorized_code".',
644
+ name: 'DataError'
645
+ });
646
+ }
647
+
648
+ return {issuer: credential_issuer, preAuthorizedCode};
649
+ }
@@ -1,26 +1,69 @@
1
1
  /*!
2
- * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2022-2026 Digital Bazaar, Inc.
3
3
  */
4
4
  import {assert, fetchJSON} from '../util.js';
5
5
 
6
+ export function createAuthorizationDetailsFromOffer({
7
+ issuerConfig, offer, supportedFormats, oid4vciVersion
8
+ } = {}) {
9
+ if(oid4vciVersion === 'draft13' ||
10
+ !(Array.isArray(issuerConfig.authorization_details_types_supported) &&
11
+ issuerConfig.authorization_details_types_supported
12
+ .includes('openid_credential'))) {
13
+ // issuer does not support authz details
14
+ return;
15
+ }
16
+
17
+ // build authz details from configs that match `offer` and `supportedFormats`
18
+ const configs = getCredentialConfigurations({
19
+ issuerConfig, offer, supportedFormats
20
+ });
21
+ const authorizationDetails = configs.map(
22
+ c => ({
23
+ type: 'openid_credential',
24
+ credential_configuration_id: c.id
25
+ }));
26
+ // only return details if there are matching configuration IDs
27
+ return authorizationDetails.length > 0 ? authorizationDetails : undefined;
28
+ }
29
+
6
30
  export function createCredentialRequestsFromOffer({
7
- issuerConfig, offer, format
31
+ issuerConfig, offer, format, authorizationDetails, oid4vciVersion
8
32
  } = {}) {
9
- // get any supported credential configurations from issuer config
10
- const supported = _createSupportedCredentialsMap({issuerConfig});
11
-
12
- // build requests from credentials identified in `offer` and remove any
13
- // that do not match the given format
14
- const credentials = offer.credential_configuration_ids ?? offer.credentials;
15
- const requests = credentials.map(c => {
16
- if(typeof c === 'string') {
17
- // use supported credential config
18
- return _getSupportedCredentialById({id: c, supported});
19
- }
20
- return c;
21
- }).filter(r => r.format === format);
33
+ // get credential configs that match `offer` and `format`
34
+ const matchingConfigurations = getCredentialConfigurations({
35
+ issuerConfig, offer, supportedFormats: [format]
36
+ });
22
37
 
23
- if(requests.length === 0) {
38
+ // build requests...
39
+ let requests;
40
+
41
+ // the presence of `authorizationDetails` triggers OID4VCI 1.0+ format,
42
+ // which uses `credential_identifier` instead of
43
+ // Draft 13 `format` + `credential_definition`
44
+ if(authorizationDetails && oid4vciVersion !== 'draft13') {
45
+ // add a request for each `credential_identifier` mentioned in each
46
+ // matching configuration
47
+ requests = [];
48
+ const matchingIds = new Set(matchingConfigurations.map(({id}) => id));
49
+ for(const element of authorizationDetails) {
50
+ const {
51
+ type, credential_configuration_id, credential_identifiers = []
52
+ } = element;
53
+ if(type !== 'openid_credential') {
54
+ continue;
55
+ }
56
+ if(matchingIds.has(credential_configuration_id)) {
57
+ requests.push(
58
+ ...credential_identifiers.map(id => ({credential_identifier: id})));
59
+ }
60
+ }
61
+ } else {
62
+ // OID4VCI Draft 13 request format
63
+ requests = matchingConfigurations.map(
64
+ ({format, credential_definition}) => ({format, credential_definition}));
65
+ }
66
+ if(!(requests?.length > 0)) {
24
67
  throw new Error(
25
68
  `No supported credential(s) with format "${format}" found.`);
26
69
  }
@@ -65,6 +108,49 @@ export async function getCredentialOffer({url, agent} = {}) {
65
108
  return response.data;
66
109
  }
67
110
 
111
+ export function getCredentialConfigurations({
112
+ issuerConfig, offer, credentialConfigurationIds, supportedFormats
113
+ } = {}) {
114
+ // get all supported credential configurations from issuer config
115
+ let supported = [
116
+ ..._createSupportedCredentialsMap({issuerConfig}).entries()]
117
+ .map(([id, c]) => ({id, ...c}));
118
+
119
+ // compute `credentialConfigurationIds` from `offer` as necessary
120
+ if(offer && !credentialConfigurationIds) {
121
+ if(offer.credential_configuration_ids) {
122
+ credentialConfigurationIds = offer.credential_configuration_ids;
123
+ } else if(offer.credentials) {
124
+ // allow legacy `offer.credentials` that express IDs
125
+ credentialConfigurationIds = offer.credentials
126
+ .map(c => typeof c === 'string' ? c : undefined)
127
+ .filter(c => c !== undefined);
128
+
129
+ // if no IDs; handle degenerate case of objects expressed in
130
+ // `offer.credentials` for pre-draft 13, to be dropped in a future major
131
+ // release that also drops draft 13 support
132
+ if(credentialConfigurationIds.length === 0) {
133
+ supported = offer.credentials.filter(c => typeof c === 'object');
134
+ credentialConfigurationIds = undefined;
135
+ }
136
+ }
137
+ }
138
+
139
+ // filter by IDs, if given
140
+ if(credentialConfigurationIds) {
141
+ const idSet = new Set(credentialConfigurationIds);
142
+ supported = supported.filter(c => idSet.has(c.id));
143
+ }
144
+
145
+ // filter by supported formats, if given
146
+ if(supportedFormats) {
147
+ const formatSet = new Set(supportedFormats);
148
+ supported = supported.filter(c => formatSet.has(c.format));
149
+ }
150
+
151
+ return supported;
152
+ }
153
+
68
154
  export function parseCredentialOfferUrl({url} = {}) {
69
155
  assert(url, 'url', 'string');
70
156
 
@@ -116,23 +202,3 @@ function _createSupportedCredentialsMap({issuerConfig}) {
116
202
 
117
203
  return supported;
118
204
  }
119
-
120
- function _getSupportedCredentialById({id, supported}) {
121
- const meta = supported.get(id);
122
- if(!meta) {
123
- throw new Error(`No supported credential "${id}" found.`);
124
- }
125
- const {format, credential_definition} = meta;
126
- if(typeof format !== 'string') {
127
- throw new Error(
128
- `Invalid supported credential "${id}"; "format" not specified.`);
129
- }
130
- if(!(Array.isArray(credential_definition?.['@context']) &&
131
- (Array.isArray(credential_definition?.types) ||
132
- Array.isArray(credential_definition?.type)))) {
133
- throw new Error(
134
- `Invalid supported credential "${id}"; "credential_definition" not ` +
135
- 'fully specified.');
136
- }
137
- return {format, credential_definition};
138
- }
package/lib/util.js CHANGED
@@ -39,7 +39,7 @@ export function base64Encode(data) {
39
39
  }
40
40
 
41
41
  export function createNamedError({message, name, details, cause} = {}) {
42
- const error = new Error(message, {cause});
42
+ const error = cause ? new Error(message, {cause}) : new Error(message);
43
43
  error.name = name;
44
44
  if(details) {
45
45
  error.details = details;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalbazaar/oid4-client",
3
- "version": "5.9.1",
3
+ "version": "5.10.0",
4
4
  "description": "An OID4 (VC + VP) client",
5
5
  "homepage": "https://github.com/digitalbazaar/oid4-client",
6
6
  "author": {