@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 +89 -29
- package/lib/index.js +3 -1
- package/lib/util.js +84 -4
- package/package.json +1 -1
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 {
|
|
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({
|
|
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({
|
|
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
|
|
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 {
|
|
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(
|
|
220
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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));
|