@digitalbazaar/oid4-client 3.5.0 → 3.7.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,7 @@
1
1
  /*!
2
2
  * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
- import {discoverIssuer, generateDIDProofJWT} from './util.js';
4
+ import {generateDIDProofJWT, robustDiscoverIssuer} from './util.js';
5
5
  import {httpClient} from '@digitalbazaar/http-client';
6
6
 
7
7
  const GRANT_TYPES = new Map([
@@ -27,7 +27,9 @@ export class OID4Client {
27
27
  if(!offer) {
28
28
  throw new TypeError('"credentialDefinition" must be an object.');
29
29
  }
30
- requests = _createCredentialRequestsFromOffer({issuerConfig, offer});
30
+ requests = _createCredentialRequestsFromOffer({
31
+ issuerConfig, offer, format
32
+ });
31
33
  if(requests.length > 1) {
32
34
  throw new Error(
33
35
  'More than one credential is offered; ' +
@@ -48,7 +50,9 @@ export class OID4Client {
48
50
  } = {}) {
49
51
  const {issuerConfig, offer} = this;
50
52
  if(requests === undefined && offer) {
51
- requests = _createCredentialRequestsFromOffer({issuerConfig, offer});
53
+ requests = _createCredentialRequestsFromOffer({
54
+ issuerConfig, offer, format
55
+ });
52
56
  } else if(!(Array.isArray(requests) && requests.length > 0)) {
53
57
  throw new TypeError('"requests" must be an array of length >= 1.');
54
58
  }
@@ -159,7 +163,7 @@ export class OID4Client {
159
163
  nonce,
160
164
  // the entity identified by the DID is issuing this JWT
161
165
  iss: did,
162
- // audience MUST be the target issuer per the OID4VC spec
166
+ // audience MUST be the target issuer per the OID4VCI spec
163
167
  aud
164
168
  });
165
169
 
@@ -206,7 +210,12 @@ export class OID4Client {
206
210
  // create a client from a credential offer
207
211
  static async fromCredentialOffer({offer, agent} = {}) {
208
212
  // validate offer
209
- const {credential_issuer, credentials, grants = {}} = offer;
213
+ const {
214
+ credential_issuer,
215
+ credentials,
216
+ credential_configuration_ids,
217
+ grants = {}
218
+ } = offer;
210
219
  let parsedIssuer;
211
220
  try {
212
221
  parsedIssuer = new URL(credential_issuer);
@@ -216,8 +225,20 @@ export class OID4Client {
216
225
  } catch(cause) {
217
226
  throw new Error('"offer.credential_issuer" is not valid.', {cause});
218
227
  }
219
- if(!(Array.isArray(credentials) && credentials.length > 0 &&
220
- credentials.every(c => c && typeof c === 'object'))) {
228
+ if(credentials === undefined &&
229
+ credential_configuration_ids === undefined) {
230
+ throw new Error(
231
+ 'Either "offer.credential_configuration_ids" or ' +
232
+ '"offer.credentials" is required.');
233
+ }
234
+ if(credential_configuration_ids !== undefined &&
235
+ !Array.isArray(credential_configuration_ids)) {
236
+ throw new Error('"offer.credential_configuration_ids" is not valid.');
237
+ }
238
+ if(credentials !== undefined &&
239
+ !(Array.isArray(credentials) && credentials.length > 0 &&
240
+ credentials.every(c => c && (
241
+ typeof c === 'object' || typeof c === 'string')))) {
221
242
  throw new Error('"offer.credentials" is not valid.');
222
243
  }
223
244
  const grant = grants[GRANT_TYPES.get('preAuthorizedCode')];
@@ -227,6 +248,7 @@ export class OID4Client {
227
248
  }
228
249
  const {
229
250
  'pre-authorized_code': preAuthorizedCode,
251
+ // FIXME: update to `tx_code` terminology
230
252
  user_pin_required: userPinRequired
231
253
  } = grant;
232
254
  if(!preAuthorizedCode) {
@@ -238,11 +260,9 @@ export class OID4Client {
238
260
 
239
261
  try {
240
262
  // discover issuer info
241
- const issuerConfigUrl =
242
- `${parsedIssuer.origin}/.well-known/openid-credential-issuer` +
243
- parsedIssuer.pathname;
244
- const {issuerConfig, metadata} = await discoverIssuer(
245
- {issuerConfigUrl, agent});
263
+ const {issuerConfig, metadata} = await robustDiscoverIssuer({
264
+ issuer: credential_issuer, agent
265
+ });
246
266
 
247
267
  /* First get access token from AS (Authorization Server), e.g.:
248
268
 
@@ -306,8 +326,9 @@ export class OID4Client {
306
326
  }
307
327
 
308
328
  // create client w/access token
309
- return new OID4Client(
310
- {accessToken, agent, issuerConfig, metadata, offer});
329
+ return new OID4Client({
330
+ accessToken, agent, issuerConfig, metadata, offer
331
+ });
311
332
  } catch(cause) {
312
333
  const error = new Error('Could not create OID4 client.', {cause});
313
334
  error.name = 'OperationError';
@@ -376,9 +397,58 @@ function _isPresentationRequired(error) {
376
397
  return error.status === 400 && errorType === 'presentation_required';
377
398
  }
378
399
 
379
- function _createCredentialRequestFromId({id, issuerConfig}) {
380
- const {credentials_supported: supported = []} = issuerConfig;
381
- const meta = supported.find(d => d.id === id);
400
+ function _createCredentialRequestsFromOffer({
401
+ issuerConfig, offer, format
402
+ }) {
403
+ // get any supported credential configurations from issuer config
404
+ const supported = _createSupportedCredentialsMap({issuerConfig});
405
+
406
+ // build requests from credentials identified in `offer` and remove any
407
+ // that do not match the given format
408
+ const credentials = offer.credential_configuration_ids ?? offer.credentials;
409
+ const requests = credentials.map(c => {
410
+ if(typeof c === 'string') {
411
+ // use supported credential config
412
+ return _getSupportedCredentialById({id: c, supported});
413
+ }
414
+ return c;
415
+ }).filter(r => r.format === format);
416
+
417
+ if(requests.length === 0) {
418
+ throw new Error(
419
+ `No supported credential(s) with format "${format}" found.`);
420
+ }
421
+
422
+ return requests;
423
+ }
424
+
425
+ function _createSupportedCredentialsMap({issuerConfig}) {
426
+ const {
427
+ credential_configurations_supported,
428
+ credentials_supported
429
+ } = issuerConfig;
430
+
431
+ let supported;
432
+ if(credential_configurations_supported &&
433
+ typeof credential_configurations_supported === 'object') {
434
+ supported = new Map(Object.entries(
435
+ issuerConfig.credential_configurations_supported));
436
+ } else if(Array.isArray(credentials_supported)) {
437
+ // handle legacy `credentials_supported` array
438
+ supported = new Map();
439
+ for(const entry of issuerConfig.credentials_supported) {
440
+ supported.set(entry.id, entry);
441
+ }
442
+ } else {
443
+ // no supported credentials from issuer config
444
+ supported = new Map();
445
+ }
446
+
447
+ return supported;
448
+ }
449
+
450
+ function _getSupportedCredentialById({id, supported}) {
451
+ const meta = supported.get(id);
382
452
  if(!meta) {
383
453
  throw new Error(`No supported credential "${id}" found.`);
384
454
  }
@@ -388,21 +458,11 @@ function _createCredentialRequestFromId({id, issuerConfig}) {
388
458
  `Invalid supported credential "${id}"; "format" not specified.`);
389
459
  }
390
460
  if(!(Array.isArray(credential_definition?.['@context']) &&
391
- Array.isArray(credential_definition?.types))) {
461
+ (Array.isArray(credential_definition?.types) ||
462
+ Array.isArray(credential_definition?.type)))) {
392
463
  throw new Error(
393
464
  `Invalid supported credential "${id}"; "credential_definition" not ` +
394
465
  'fully specified.');
395
466
  }
396
467
  return {format, credential_definition};
397
468
  }
398
-
399
- function _createCredentialRequestsFromOffer({issuerConfig, offer}) {
400
- // build requests from `offer`
401
- return offer.credentials.map(c => {
402
- if(typeof c === 'string') {
403
- // use issuer config metadata to dereference string
404
- return _createCredentialRequestFromId({id: c, issuerConfig});
405
- }
406
- return c;
407
- });
408
- }
package/lib/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /*!
2
- * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
4
  export * as oid4vp from './oid4vp.js';
5
5
  export {
6
6
  discoverIssuer,
7
7
  generateDIDProofJWT,
8
+ getCredentialOffer,
8
9
  parseCredentialOfferUrl,
10
+ robustDiscoverIssuer,
9
11
  signJWT
10
12
  } from './util.js';
11
13
  export {OID4Client} from './OID4Client.js';
package/lib/util.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
4
  import * as base64url from 'base64url-universal';
5
5
  import {httpClient} from '@digitalbazaar/http-client';
@@ -46,6 +46,14 @@ export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
46
46
  throw error;
47
47
  }
48
48
 
49
+ // ensure `credential_issuer` matches `issuer`, if present
50
+ const {credential_issuer} = issuerMetaData;
51
+ if(credential_issuer !== undefined && credential_issuer !== issuer) {
52
+ const error = new Error('"credential_issuer" must match "issuer".');
53
+ error.name = 'DataError';
54
+ throw error;
55
+ }
56
+
49
57
  /* Validate `issuer` value against `issuerConfigUrl` (per RFC 8414):
50
58
 
51
59
  The `origin` and `path` element must be parsed from `issuer` and checked
@@ -64,9 +72,19 @@ export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
64
72
  expectedConfigUrl += pathname;
65
73
  }
66
74
  if(issuerConfigUrl !== expectedConfigUrl) {
67
- const error = new Error('"issuer" does not match configuration URL.');
68
- error.name = 'DataError';
69
- throw error;
75
+ // alternatively, against RFC 8414, but according to OID4VCI, make sure
76
+ // the issuer config URL matches:
77
+ // <origin><path>/.well-known/<any-path-segment>
78
+ expectedConfigUrl = origin;
79
+ if(pathname !== '/') {
80
+ expectedConfigUrl += pathname;
81
+ }
82
+ expectedConfigUrl += `/.well-known/${anyPathSegment}`;
83
+ if(issuerConfigUrl !== expectedConfigUrl) {
84
+ const error = new Error('"issuer" does not match configuration URL.');
85
+ error.name = 'DataError';
86
+ throw error;
87
+ }
70
88
  }
71
89
 
72
90
  // fetch AS meta data
@@ -144,6 +162,43 @@ export async function generateDIDProofJWT({
144
162
  return signJWT({payload, protectedHeader, signer});
145
163
  }
146
164
 
165
+ export async function getCredentialOffer({url, agent} = {}) {
166
+ const {protocol, searchParams} = new URL(url);
167
+ if(protocol !== 'openid-credential-offer:') {
168
+ throw new SyntaxError(
169
+ '"url" must express a URL with the ' +
170
+ '"openid-credential-offer" protocol.');
171
+ }
172
+ const offer = searchParams.get('credential_offer');
173
+ if(offer) {
174
+ return JSON.parse(offer);
175
+ }
176
+
177
+ // try to fetch offer from URL
178
+ const offerUrl = searchParams.get('credential_offer_uri');
179
+ if(!offerUrl) {
180
+ throw new SyntaxError(
181
+ 'OID4VCI credential offer must have "credential_offer" or ' +
182
+ '"credential_offer_uri".');
183
+ }
184
+
185
+ if(!offerUrl.startsWith('https://')) {
186
+ const error = new Error(
187
+ `"credential_offer_uri" (${offerUrl}) must start with "https://".`);
188
+ error.name = 'NotSupportedError';
189
+ throw error;
190
+ }
191
+
192
+ const response = await fetchJSON({url: offerUrl, agent});
193
+ if(!response.data) {
194
+ const error = new Error(
195
+ `Credential offer fetched from "${offerUrl}" is not JSON.`);
196
+ error.name = 'DataError';
197
+ throw error;
198
+ }
199
+ return response.data;
200
+ }
201
+
147
202
  export function parseCredentialOfferUrl({url} = {}) {
148
203
  assert(url, 'url', 'string');
149
204
 
@@ -171,6 +226,31 @@ export function parseCredentialOfferUrl({url} = {}) {
171
226
  return JSON.parse(searchParams.get('credential_offer'));
172
227
  }
173
228
 
229
+ export async function robustDiscoverIssuer({issuer, agent} = {}) {
230
+ // try issuer config URLs based on OID4VCI (first) and RFC 8414 (second)
231
+ const parsedIssuer = new URL(issuer);
232
+ const {origin} = parsedIssuer;
233
+ const path = parsedIssuer.pathname === '/' ? '' : parsedIssuer.pathname;
234
+
235
+ const issuerConfigUrls = [
236
+ // OID4VCI
237
+ `${origin}${path}/.well-known/openid-credential-issuer`,
238
+ // RFC 8414
239
+ `${origin}/.well-known/openid-credential-issuer${path}`
240
+ ];
241
+
242
+ let error;
243
+ for(const issuerConfigUrl of issuerConfigUrls) {
244
+ try {
245
+ const config = await discoverIssuer({issuerConfigUrl, agent});
246
+ return config;
247
+ } catch(e) {
248
+ error = e;
249
+ }
250
+ }
251
+ throw error;
252
+ }
253
+
174
254
  export async function signJWT({payload, protectedHeader, signer} = {}) {
175
255
  // encode payload and protected header
176
256
  const b64Payload = base64url.encode(JSON.stringify(payload));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalbazaar/oid4-client",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "description": "An OID4 (VC + VP) client",
5
5
  "homepage": "https://github.com/digitalbazaar/oid4-client",
6
6
  "author": {