@digitalbazaar/oid4-client 2.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.
- package/LICENSE +29 -0
- package/README.md +2 -0
- package/lib/OID4Client.js +385 -0
- package/lib/index.js +5 -0
- package/lib/util.js +183 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2023, Digital Bazaar, Inc.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {discoverIssuer, generateDIDProofJWT} from './util.js';
|
|
5
|
+
import {httpClient} from '@digitalbazaar/http-client';
|
|
6
|
+
|
|
7
|
+
const GRANT_TYPES = new Map([
|
|
8
|
+
['preAuthorizedCode', 'urn:ietf:params:oauth:grant-type:pre-authorized_code']
|
|
9
|
+
]);
|
|
10
|
+
const HEADERS = {accept: 'application/json'};
|
|
11
|
+
|
|
12
|
+
export class OID4Client {
|
|
13
|
+
constructor({accessToken = null, agent, issuerConfig, offer} = {}) {
|
|
14
|
+
this.accessToken = accessToken;
|
|
15
|
+
this.agent = agent;
|
|
16
|
+
this.issuerConfig = issuerConfig;
|
|
17
|
+
this.offer = offer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async requestCredential({
|
|
21
|
+
credentialDefinition, did, didProofSigner, agent
|
|
22
|
+
} = {}) {
|
|
23
|
+
const {issuerConfig, offer} = this;
|
|
24
|
+
let requests;
|
|
25
|
+
if(credentialDefinition === undefined) {
|
|
26
|
+
if(!offer) {
|
|
27
|
+
throw new TypeError('"credentialDefinition" must be an object.');
|
|
28
|
+
}
|
|
29
|
+
requests = _createCredentialRequestsFromOffer({issuerConfig, offer});
|
|
30
|
+
if(requests.length > 1) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'More than one credential is offered; ' +
|
|
33
|
+
'use "requestCredentials()" instead.');
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
requests = [{
|
|
37
|
+
format: 'ldp_vc',
|
|
38
|
+
credential_definition: credentialDefinition
|
|
39
|
+
}];
|
|
40
|
+
}
|
|
41
|
+
return this.requestCredentials({requests, did, didProofSigner, agent});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async requestCredentials({
|
|
45
|
+
requests, did, didProofSigner, agent, alwaysUseBatchEndpoint = false
|
|
46
|
+
} = {}) {
|
|
47
|
+
const {issuerConfig, offer} = this;
|
|
48
|
+
if(requests === undefined && offer) {
|
|
49
|
+
requests = _createCredentialRequestsFromOffer({issuerConfig, offer});
|
|
50
|
+
} else if(!(Array.isArray(requests) && requests.length > 0)) {
|
|
51
|
+
throw new TypeError('"requests" must be an array of length >= 1.');
|
|
52
|
+
}
|
|
53
|
+
requests.forEach(_assertRequest);
|
|
54
|
+
// set default `format`
|
|
55
|
+
requests = requests.map(r => ({format: 'ldp_vc', ...r}));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
/* First send credential request(s) to DS without DID proof JWT, e.g.:
|
|
59
|
+
|
|
60
|
+
POST /credential HTTP/1.1
|
|
61
|
+
Host: server.example.com
|
|
62
|
+
Content-Type: application/json
|
|
63
|
+
Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
"format": "ldp_vc",
|
|
67
|
+
"credential_definition": {...},
|
|
68
|
+
// only present on retry after server requests it
|
|
69
|
+
"proof": {
|
|
70
|
+
"proof_type": "jwt",
|
|
71
|
+
"jwt": "eyJraW..."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
OR (if multiple `requests` were given)
|
|
76
|
+
|
|
77
|
+
POST /batch_credential HTTP/1.1
|
|
78
|
+
Host: server.example.com
|
|
79
|
+
Content-Type: application/json
|
|
80
|
+
Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"credential_requests": [{
|
|
84
|
+
"format": "ldp_vc",
|
|
85
|
+
"credential_definition": {...},
|
|
86
|
+
// only present on retry after server requests it
|
|
87
|
+
"proof": {
|
|
88
|
+
"proof_type": "jwt",
|
|
89
|
+
"jwt": "eyJraW..."
|
|
90
|
+
}
|
|
91
|
+
}, {
|
|
92
|
+
...
|
|
93
|
+
}]
|
|
94
|
+
}
|
|
95
|
+
*/
|
|
96
|
+
let url;
|
|
97
|
+
let json;
|
|
98
|
+
if(requests.length > 1 || alwaysUseBatchEndpoint) {
|
|
99
|
+
({batch_credential_endpoint: url} = this.issuerConfig);
|
|
100
|
+
json = {credential_requests: requests};
|
|
101
|
+
} else {
|
|
102
|
+
({credential_endpoint: url} = this.issuerConfig);
|
|
103
|
+
json = {...requests[0]};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let result;
|
|
107
|
+
const headers = {
|
|
108
|
+
...HEADERS,
|
|
109
|
+
authorization: `Bearer ${this.accessToken}`
|
|
110
|
+
};
|
|
111
|
+
for(let retries = 0; retries <= 1; ++retries) {
|
|
112
|
+
try {
|
|
113
|
+
const response = await httpClient.post(url, {agent, headers, json});
|
|
114
|
+
result = response.data;
|
|
115
|
+
if(!result) {
|
|
116
|
+
const error = new Error('Credential response format is not JSON.');
|
|
117
|
+
error.name = 'DataError';
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
} catch(cause) {
|
|
122
|
+
if(!_isMissingProofError(cause)) {
|
|
123
|
+
// non-specific error case
|
|
124
|
+
throw cause;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// if `didProofSigner` is not provided, throw error
|
|
128
|
+
if(!(did && didProofSigner)) {
|
|
129
|
+
const {data: details} = cause;
|
|
130
|
+
const error = new Error('DID authentication is required.');
|
|
131
|
+
error.name = 'NotAllowedError';
|
|
132
|
+
error.cause = cause;
|
|
133
|
+
error.details = details;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// validate that `result` has
|
|
138
|
+
const {data: {c_nonce: nonce}} = cause;
|
|
139
|
+
if(!(nonce && typeof nonce === 'string')) {
|
|
140
|
+
const error = new Error('No DID proof challenge specified.');
|
|
141
|
+
error.name = 'DataError';
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// generate a DID proof JWT
|
|
146
|
+
const {issuer: aud} = this.issuerConfig;
|
|
147
|
+
const jwt = await generateDIDProofJWT({
|
|
148
|
+
signer: didProofSigner,
|
|
149
|
+
nonce,
|
|
150
|
+
// the entity identified by the DID is issuing this JWT
|
|
151
|
+
iss: did,
|
|
152
|
+
// audience MUST be the target issuer per the OID4VC spec
|
|
153
|
+
aud
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// add proof to body to be posted and loop to retry
|
|
157
|
+
const proof = {proof_type: 'jwt', jwt};
|
|
158
|
+
if(json.credential_requests) {
|
|
159
|
+
json.credential_requests = json.credential_requests.map(
|
|
160
|
+
cr => ({...cr, proof}));
|
|
161
|
+
} else {
|
|
162
|
+
json.proof = proof;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// wallet / client receives credential:
|
|
168
|
+
/* Note: The credential is not wrapped here in a VP in the current spec:
|
|
169
|
+
|
|
170
|
+
HTTP/1.1 200 OK
|
|
171
|
+
Content-Type: application/json
|
|
172
|
+
Cache-Control: no-store
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
"format": "ldp_vc"
|
|
176
|
+
"credential" : {...}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
OR (if multiple `requests` were given)
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
"credential_responses": [{
|
|
183
|
+
"format": "ldp_vc",
|
|
184
|
+
"credential": {...}
|
|
185
|
+
}]
|
|
186
|
+
}
|
|
187
|
+
*/
|
|
188
|
+
return result;
|
|
189
|
+
} catch(cause) {
|
|
190
|
+
const error = new Error('Could not receive credentials.');
|
|
191
|
+
error.name = 'OperationError';
|
|
192
|
+
error.cause = cause;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// create a client from a credential offer
|
|
198
|
+
static async fromCredentialOffer({offer, agent} = {}) {
|
|
199
|
+
// validate offer
|
|
200
|
+
const {credential_issuer, credentials, grants = {}} = offer;
|
|
201
|
+
let parsedIssuer;
|
|
202
|
+
try {
|
|
203
|
+
parsedIssuer = new URL(credential_issuer);
|
|
204
|
+
if(parsedIssuer.protocol !== 'https:') {
|
|
205
|
+
throw new Error('Only "https" credential issuer URLs are supported.');
|
|
206
|
+
}
|
|
207
|
+
} catch(e) {
|
|
208
|
+
const err = new Error('"offer.credential_issuer" is not valid.');
|
|
209
|
+
err.cause = e;
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
if(!(Array.isArray(credentials) && credentials.length > 0 &&
|
|
213
|
+
credentials.every(c => c && typeof c === 'object'))) {
|
|
214
|
+
throw new Error('"offer.credentials" is not valid.');
|
|
215
|
+
}
|
|
216
|
+
const grant = grants[GRANT_TYPES.get('preAuthorizedCode')];
|
|
217
|
+
if(!grant) {
|
|
218
|
+
// FIXME: implement `authorization_code` grant type as well
|
|
219
|
+
throw new Error('Only "pre-authorized_code" grant type is implemented.');
|
|
220
|
+
}
|
|
221
|
+
const {
|
|
222
|
+
'pre-authorized_code': preAuthorizedCode,
|
|
223
|
+
user_pin_required: userPinRequired
|
|
224
|
+
} = grant;
|
|
225
|
+
if(!preAuthorizedCode) {
|
|
226
|
+
throw new Error('"offer.grant" is missing "pre-authorized_code".');
|
|
227
|
+
}
|
|
228
|
+
if(userPinRequired) {
|
|
229
|
+
throw new Error('User pin is not implemented.');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
// discover issuer info
|
|
234
|
+
const issuerConfigUrl =
|
|
235
|
+
`${parsedIssuer.origin}/.well-known/oauth-authorization-server` +
|
|
236
|
+
parsedIssuer.pathname;
|
|
237
|
+
const issuerConfig = await discoverIssuer({issuerConfigUrl, agent});
|
|
238
|
+
|
|
239
|
+
/* First get access token from AS (Authorization Server), e.g.:
|
|
240
|
+
|
|
241
|
+
POST /token HTTP/1.1
|
|
242
|
+
Host: server.example.com
|
|
243
|
+
Content-Type: application/x-www-form-urlencoded
|
|
244
|
+
grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
|
|
245
|
+
&pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
|
|
246
|
+
&user_pin=493536
|
|
247
|
+
|
|
248
|
+
Note a bad response would look like:
|
|
249
|
+
|
|
250
|
+
/*
|
|
251
|
+
HTTP/1.1 400 Bad Request
|
|
252
|
+
Content-Type: application/json
|
|
253
|
+
Cache-Control: no-store
|
|
254
|
+
{
|
|
255
|
+
"error": "invalid_request"
|
|
256
|
+
}
|
|
257
|
+
*/
|
|
258
|
+
const body = new URLSearchParams();
|
|
259
|
+
body.set('grant_type', GRANT_TYPES.get('preAuthorizedCode'));
|
|
260
|
+
body.set('pre-authorized_code', preAuthorizedCode);
|
|
261
|
+
const {token_endpoint} = issuerConfig;
|
|
262
|
+
const response = await httpClient.post(token_endpoint, {
|
|
263
|
+
agent, body, headers: HEADERS
|
|
264
|
+
});
|
|
265
|
+
const {data: result} = response;
|
|
266
|
+
if(!result) {
|
|
267
|
+
const error = new Error(
|
|
268
|
+
'Could not get access token; response is not JSON.');
|
|
269
|
+
error.name = 'DataError';
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* Validate response body (Note: Do not check or use `c_nonce*` here
|
|
274
|
+
because it conflates AS with DS (Delivery Server)), e.g.:
|
|
275
|
+
|
|
276
|
+
HTTP/1.1 200 OK
|
|
277
|
+
Content-Type: application/json
|
|
278
|
+
Cache-Control: no-store
|
|
279
|
+
|
|
280
|
+
{
|
|
281
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ",
|
|
282
|
+
"token_type": "bearer",
|
|
283
|
+
"expires_in": 86400
|
|
284
|
+
}
|
|
285
|
+
*/
|
|
286
|
+
const {access_token: accessToken, token_type} = result;
|
|
287
|
+
if(!(accessToken && typeof accessToken === 'string')) {
|
|
288
|
+
const error = new Error(
|
|
289
|
+
'Invalid access token response; "access_token" must be a string.');
|
|
290
|
+
error.name = 'DataError';
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
if(token_type !== 'bearer') {
|
|
294
|
+
const error = new Error(
|
|
295
|
+
'Invalid access token response; "token_type" must be a "bearer".');
|
|
296
|
+
error.name = 'DataError';
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// create client w/access token
|
|
301
|
+
return new OID4Client({accessToken, agent, issuerConfig, offer});
|
|
302
|
+
} catch(cause) {
|
|
303
|
+
const error = new Error('Could not create OID4 client.');
|
|
304
|
+
error.name = 'OperationError';
|
|
305
|
+
error.cause = cause;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _assertRequest(request) {
|
|
312
|
+
if(!(request && typeof request === 'object')) {
|
|
313
|
+
throw new TypeError('"request" must be an object.');
|
|
314
|
+
}
|
|
315
|
+
const {credential_definition, format} = request;
|
|
316
|
+
if(format !== undefined && format !== 'ldp_vc') {
|
|
317
|
+
throw new TypeError('Credential request "format" must be "ldp_vc".');
|
|
318
|
+
}
|
|
319
|
+
if(!(credential_definition && typeof credential_definition === 'object')) {
|
|
320
|
+
throw new TypeError(
|
|
321
|
+
'Credential request "credential_definition" must be an object.');
|
|
322
|
+
}
|
|
323
|
+
const {'@context': context, type: type} = credential_definition;
|
|
324
|
+
if(!(Array.isArray(context) && context.length > 0)) {
|
|
325
|
+
throw new TypeError(
|
|
326
|
+
'Credential definition "@context" must be an array of length >= 1.');
|
|
327
|
+
}
|
|
328
|
+
if(!(Array.isArray(type) && type.length > 0)) {
|
|
329
|
+
throw new TypeError(
|
|
330
|
+
'Credential definition "type" must be an array of length >= 2.');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function _isMissingProofError(error) {
|
|
335
|
+
/* If DID authn is required, delivery server sends, e.g.:
|
|
336
|
+
|
|
337
|
+
HTTP/1.1 400 Bad Request
|
|
338
|
+
Content-Type: application/json
|
|
339
|
+
Cache-Control: no-store
|
|
340
|
+
|
|
341
|
+
{
|
|
342
|
+
"error": "invalid_or_missing_proof"
|
|
343
|
+
"error_description":
|
|
344
|
+
"Credential issuer requires proof element in Credential Request"
|
|
345
|
+
"c_nonce": "8YE9hCnyV2",
|
|
346
|
+
"c_nonce_expires_in": 86400
|
|
347
|
+
}
|
|
348
|
+
*/
|
|
349
|
+
return error.status === 400 &&
|
|
350
|
+
error?.data?.error === 'invalid_or_missing_proof';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function _createCredentialRequestFromId({id, issuerConfig}) {
|
|
354
|
+
const {credentials_supported: supported = []} = issuerConfig;
|
|
355
|
+
const meta = supported.find(d => d.id === id);
|
|
356
|
+
if(!meta) {
|
|
357
|
+
throw new Error(`No supported credential "${id}" found.`);
|
|
358
|
+
}
|
|
359
|
+
const {format, credential_definition} = meta;
|
|
360
|
+
if(typeof format !== 'string') {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Invalid supported credential "${id}"; "format" not specified.`);
|
|
363
|
+
}
|
|
364
|
+
if(format !== 'ldp_vc') {
|
|
365
|
+
throw new Error(`Unsupported "format" "${format}".`);
|
|
366
|
+
}
|
|
367
|
+
if(!(Array.isArray(credential_definition?.['@context']) &&
|
|
368
|
+
Array.isArray(credential_definition?.types))) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Invalid supported credential "${id}"; "credential_definition" not ` +
|
|
371
|
+
'fully specified.');
|
|
372
|
+
}
|
|
373
|
+
return {format, credential_definition};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function _createCredentialRequestsFromOffer({issuerConfig, offer}) {
|
|
377
|
+
// build requests from `offer`
|
|
378
|
+
return offer.credentials.map(c => {
|
|
379
|
+
if(typeof c === 'string') {
|
|
380
|
+
// use issuer config metadata to dereference string
|
|
381
|
+
return _createCredentialRequestFromId({id: c, issuerConfig});
|
|
382
|
+
}
|
|
383
|
+
return c;
|
|
384
|
+
});
|
|
385
|
+
}
|
package/lib/index.js
ADDED
package/lib/util.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import * as base64url from 'base64url-universal';
|
|
5
|
+
import {httpClient} from '@digitalbazaar/http-client';
|
|
6
|
+
|
|
7
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
8
|
+
const ENCODED_PERIOD = TEXT_ENCODER.encode('.');
|
|
9
|
+
const WELL_KNOWN_REGEX = /\/\.well-known\/([^\/]+)/;
|
|
10
|
+
|
|
11
|
+
export async function discoverIssuer({issuerConfigUrl, agent} = {}) {
|
|
12
|
+
try {
|
|
13
|
+
if(!(issuerConfigUrl && typeof issuerConfigUrl === 'string')) {
|
|
14
|
+
throw new TypeError('"issuerConfigUrl" must be a string.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// allow these params to be passed / configured
|
|
18
|
+
const fetchOptions = {
|
|
19
|
+
// max size for issuer config related responses (in bytes, ~4 KiB)
|
|
20
|
+
size: 4096,
|
|
21
|
+
// timeout in ms for fetching an issuer config
|
|
22
|
+
timeout: 5000,
|
|
23
|
+
agent
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const response = await httpClient.get(issuerConfigUrl, fetchOptions);
|
|
27
|
+
if(!response.data) {
|
|
28
|
+
const error = new Error('Issuer configuration format is not JSON.');
|
|
29
|
+
error.name = 'DataError';
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {data: config} = response;
|
|
34
|
+
const {issuer, token_endpoint} = config;
|
|
35
|
+
|
|
36
|
+
// validate `issuer`
|
|
37
|
+
if(!(typeof issuer === 'string' && issuer.startsWith('https://'))) {
|
|
38
|
+
const error = new Error('"issuer" is not an HTTPS URL.');
|
|
39
|
+
error.name = 'DataError';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Validate `issuer` value against `issuerConfigUrl` (per RFC 8414):
|
|
44
|
+
|
|
45
|
+
The `origin` and `path` element must be parsed from `issuer` and checked
|
|
46
|
+
against `issuerConfigUrl` like so:
|
|
47
|
+
|
|
48
|
+
For issuer `<origin>` (no path), `issuerConfigUrl` must match:
|
|
49
|
+
`<origin>/.well-known/<any-path-segment>`
|
|
50
|
+
|
|
51
|
+
For issuer `<origin><path>`, `issuerConfigUrl` must be:
|
|
52
|
+
`<origin>/.well-known/<any-path-segment><path>` */
|
|
53
|
+
const {pathname: wellKnownPath} = new URL(issuerConfigUrl);
|
|
54
|
+
const anyPathSegment = wellKnownPath.match(WELL_KNOWN_REGEX)[1];
|
|
55
|
+
const {origin, pathname} = new URL(issuer);
|
|
56
|
+
let expectedConfigUrl = `${origin}/.well-known/${anyPathSegment}`;
|
|
57
|
+
if(pathname !== '/') {
|
|
58
|
+
expectedConfigUrl += pathname;
|
|
59
|
+
}
|
|
60
|
+
if(issuerConfigUrl !== expectedConfigUrl) {
|
|
61
|
+
const error = new Error('"issuer" does not match configuration URL.');
|
|
62
|
+
error.name = 'DataError';
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ensure `token_endpoint` is valid
|
|
67
|
+
if(!(token_endpoint && typeof token_endpoint === 'string')) {
|
|
68
|
+
const error = new TypeError('"token_endpoint" must be a string.');
|
|
69
|
+
error.name = 'DataError';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return config;
|
|
74
|
+
} catch(cause) {
|
|
75
|
+
const error = new Error('Could not get OAuth2 issuer configuration.');
|
|
76
|
+
error.name = 'OperationError';
|
|
77
|
+
error.cause = cause;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function generateDIDProofJWT({
|
|
83
|
+
signer, nonce, iss, aud, exp, nbf
|
|
84
|
+
} = {}) {
|
|
85
|
+
/* Example:
|
|
86
|
+
{
|
|
87
|
+
"alg": "ES256",
|
|
88
|
+
"kid":"did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1"
|
|
89
|
+
}.
|
|
90
|
+
{
|
|
91
|
+
"iss": "s6BhdRkqt3",
|
|
92
|
+
"aud": "https://server.example.com",
|
|
93
|
+
"iat": 1659145924,
|
|
94
|
+
"nonce": "tZignsnFbp"
|
|
95
|
+
}
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
if(exp === undefined) {
|
|
99
|
+
// default to 5 minute expiration time
|
|
100
|
+
exp = Math.floor(Date.now() / 1000) + 60 * 5;
|
|
101
|
+
}
|
|
102
|
+
if(nbf === undefined) {
|
|
103
|
+
// default to now
|
|
104
|
+
nbf = Math.floor(Date.now() / 1000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const {id: kid} = signer;
|
|
108
|
+
const alg = _curveToAlg(signer.algorithm);
|
|
109
|
+
const payload = {nonce, iss, aud, exp, nbf};
|
|
110
|
+
const protectedHeader = {alg, kid};
|
|
111
|
+
|
|
112
|
+
return signJWT({payload, protectedHeader, signer});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseCredentialOfferUrl({url} = {}) {
|
|
116
|
+
if(!(url && typeof url === 'string')) {
|
|
117
|
+
throw new TypeError('"url" must be a string.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Parse URL, e.g.:
|
|
121
|
+
|
|
122
|
+
'openid-credential-offer://?' +
|
|
123
|
+
'credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2F' +
|
|
124
|
+
'localhost%3A18443%2Fexchangers%2Fz19t8xb568tNRD1zVm9R5diXR%2F' +
|
|
125
|
+
'exchanges%2Fz1ADs3ur2s9tm6JUW6CnTiyn3%22%2C%22credentials' +
|
|
126
|
+
'%22%3A%5B%7B%22format%22%3A%22ldp_vc%22%2C%22credential_definition' +
|
|
127
|
+
'%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww.w3.org%2F2018%2F' +
|
|
128
|
+
'credentials%2Fv1%22%2C%22https%3A%2F%2Fwww.w3.org%2F2018%2F' +
|
|
129
|
+
'credentials%2Fexamples%2Fv1%22%5D%2C%22type%22%3A%5B%22' +
|
|
130
|
+
'VerifiableCredential%22%2C%22UniversityDegreeCredential' +
|
|
131
|
+
'%22%5D%7D%7D%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams' +
|
|
132
|
+
'%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22' +
|
|
133
|
+
'pre-authorized_code%22%3A%22z1AEvnk2cqeRM1Mfv75vzHSUo%22%7D%7D%7D';
|
|
134
|
+
*/
|
|
135
|
+
const {protocol, searchParams} = new URL(url);
|
|
136
|
+
if(protocol !== 'openid-credential-offer:') {
|
|
137
|
+
throw new SyntaxError(
|
|
138
|
+
'"url" must express a URL with the ' +
|
|
139
|
+
'"openid-credential-offer" protocol.');
|
|
140
|
+
}
|
|
141
|
+
return JSON.parse(searchParams.get('credential_offer'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function signJWT({payload, protectedHeader, signer} = {}) {
|
|
145
|
+
// encode payload and protected header
|
|
146
|
+
const b64Payload = base64url.encode(JSON.stringify(payload));
|
|
147
|
+
const b64ProtectedHeader = base64url.encode(JSON.stringify(protectedHeader));
|
|
148
|
+
payload = TEXT_ENCODER.encode(b64Payload);
|
|
149
|
+
protectedHeader = TEXT_ENCODER.encode(b64ProtectedHeader);
|
|
150
|
+
|
|
151
|
+
// concatenate
|
|
152
|
+
const data = new Uint8Array(
|
|
153
|
+
protectedHeader.length + ENCODED_PERIOD.length + payload.length);
|
|
154
|
+
data.set(protectedHeader);
|
|
155
|
+
data.set(ENCODED_PERIOD, protectedHeader.length);
|
|
156
|
+
data.set(payload, protectedHeader.length + ENCODED_PERIOD.length);
|
|
157
|
+
|
|
158
|
+
// sign
|
|
159
|
+
const signature = await signer.sign({data});
|
|
160
|
+
|
|
161
|
+
// create JWS
|
|
162
|
+
const jws = {
|
|
163
|
+
signature: base64url.encode(signature),
|
|
164
|
+
payload: b64Payload,
|
|
165
|
+
protected: b64ProtectedHeader
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// create compact JWT
|
|
169
|
+
return `${jws.protected}.${jws.payload}.${jws.signature}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _curveToAlg(crv) {
|
|
173
|
+
if(crv === 'Ed25519' || crv === 'Ed448') {
|
|
174
|
+
return 'EdDSA';
|
|
175
|
+
}
|
|
176
|
+
if(crv?.startsWith('P-')) {
|
|
177
|
+
return `ES${crv.slice(2)}`;
|
|
178
|
+
}
|
|
179
|
+
if(crv === 'secp256k1') {
|
|
180
|
+
return 'ES256K';
|
|
181
|
+
}
|
|
182
|
+
return crv;
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@digitalbazaar/oid4-client",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "An OID4 (VC + VP) client",
|
|
5
|
+
"homepage": "https://github.com/digitalbazaar/oid4-client",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Digital Bazaar, Inc.",
|
|
8
|
+
"email": "support@digitalbazaar.com",
|
|
9
|
+
"url": "https://digitalbazaar.com/"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/digitalbazaar/oid4-client"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/digitalbazaar/oid4-client/issues",
|
|
17
|
+
"email": "support@digitalbazaar.com"
|
|
18
|
+
},
|
|
19
|
+
"license": "BSD-3-Clause",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": "./lib/index.js",
|
|
22
|
+
"files": [
|
|
23
|
+
"lib/**/*.js"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@digitalbazaar/http-client": "^3.2.0",
|
|
27
|
+
"base64url-universal": "^2.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"c8": "^7.11.3",
|
|
31
|
+
"chai": "^4.3.6",
|
|
32
|
+
"cross-env": "^7.0.3",
|
|
33
|
+
"eslint": "^8.41.0",
|
|
34
|
+
"eslint-config-digitalbazaar": "^5.0.1",
|
|
35
|
+
"eslint-plugin-jsdoc": "^45.0.0",
|
|
36
|
+
"eslint-plugin-unicorn": "^42.0.0",
|
|
37
|
+
"jsdoc": "^4.0.2",
|
|
38
|
+
"jsdoc-to-markdown": "^8.0.0",
|
|
39
|
+
"karma": "^6.3.20",
|
|
40
|
+
"karma-chai": "^0.1.0",
|
|
41
|
+
"karma-chrome-launcher": "^3.1.1",
|
|
42
|
+
"karma-mocha": "^2.0.1",
|
|
43
|
+
"karma-mocha-reporter": "^2.2.5",
|
|
44
|
+
"karma-sourcemap-loader": "^0.3.8",
|
|
45
|
+
"karma-webpack": "^5.0.0",
|
|
46
|
+
"mocha": "^10.0.0",
|
|
47
|
+
"mocha-lcov-reporter": "^1.3.0",
|
|
48
|
+
"webpack": "^5.73.0"
|
|
49
|
+
},
|
|
50
|
+
"c8": {
|
|
51
|
+
"reporter": [
|
|
52
|
+
"lcov",
|
|
53
|
+
"text-summary",
|
|
54
|
+
"text"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=16"
|
|
59
|
+
},
|
|
60
|
+
"keywords": [
|
|
61
|
+
"OID4",
|
|
62
|
+
"OID4VCI",
|
|
63
|
+
"OID4VC",
|
|
64
|
+
"OID4VP",
|
|
65
|
+
"OIDC4VCI"
|
|
66
|
+
],
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "npm run test-node",
|
|
69
|
+
"test-node": "cross-env NODE_ENV=test mocha --preserve-symlinks -t 10000 -r tests/node.js tests/**/*.spec.js",
|
|
70
|
+
"test-karma": "karma start tests/karma.conf.cjs",
|
|
71
|
+
"coverage": "cross-env NODE_ENV=test c8 npm run test-node",
|
|
72
|
+
"coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly --reporter=text-summary --reporter=text npm run test-node",
|
|
73
|
+
"coverage-report": "c8 report",
|
|
74
|
+
"generate-readme": "jsdoc2md -t readme-template.hbs lib/*.js > README.md",
|
|
75
|
+
"lint": "eslint ."
|
|
76
|
+
}
|
|
77
|
+
}
|