@bedrock/vc-delivery 5.0.1 → 5.2.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/http.js CHANGED
@@ -1,24 +1,21 @@
1
1
  /*!
2
2
  * Copyright (c) 2018-2024 Digital Bazaar, Inc. All rights reserved.
3
3
  */
4
- import * as _openId from './openId.js';
5
4
  import * as bedrock from '@bedrock/core';
6
5
  import * as exchanges from './exchanges.js';
6
+ import * as oid4 from './oid4/http.js';
7
+ import {createExchange, processExchange} from './vcapi.js';
7
8
  import {
8
9
  createExchangeBody, useExchangeBody
9
10
  } from '../schemas/bedrock-vc-workflow.js';
10
- import {exportJWK, generateKeyPair, importJWK} from 'jose';
11
- import {generateRandom, getWorkflowId} from './helpers.js';
12
11
  import {metering, middleware} from '@bedrock/service-core';
13
12
  import {asyncHandler} from '@bedrock/express';
14
13
  import bodyParser from 'body-parser';
15
14
  import cors from 'cors';
15
+ import {getWorkflowId} from './helpers.js';
16
16
  import {logger} from './logger.js';
17
- import {processExchange} from './vcapi.js';
18
17
  import {createValidateMiddleware as validate} from '@bedrock/validation';
19
18
 
20
- const {util: {BedrockError}} = bedrock;
21
-
22
19
  // FIXME: remove and apply at top-level application
23
20
  bedrock.events.on('bedrock-express.configure.bodyParser', app => {
24
21
  app.use(bodyParser.json({
@@ -73,75 +70,15 @@ export async function addRoutes({app, service} = {}) {
73
70
  // FIXME: check available storage via meter before allowing operation
74
71
 
75
72
  try {
76
- const {config} = req.serviceObject;
73
+ const {config: workflow} = req.serviceObject;
77
74
  const {
78
75
  ttl, openId, variables = {},
79
76
  // allow steps to be skipped by creator as needed
80
- step = config.initialStep
77
+ step = workflow.initialStep
81
78
  } = req.body;
82
-
83
- // validate exchange step, if given
84
- if(step && !(step in config.steps)) {
85
- throw new BedrockError(`Undefined step "${step}".`, {
86
- name: 'DataError',
87
- details: {httpStatusCode: 400, public: true}
88
- });
89
- }
90
-
91
- if(openId) {
92
- // either issuer instances or a single issuer zcap be given if
93
- // any expected credential requests are given
94
- const {expectedCredentialRequests} = openId;
95
- if(expectedCredentialRequests &&
96
- !(config.issuerInstances || config.zcaps.issue)) {
97
- throw new BedrockError(
98
- 'Credential requests are not supported by this workflow.', {
99
- name: 'DataError',
100
- details: {httpStatusCode: 400, public: true}
101
- });
102
- }
103
-
104
- // perform key generation if requested
105
- if(openId.oauth2?.generateKeyPair) {
106
- const {oauth2} = openId;
107
- const {algorithm} = oauth2.generateKeyPair;
108
- const kp = await generateKeyPair(algorithm, {extractable: true});
109
- const [privateKeyJwk, publicKeyJwk] = await Promise.all([
110
- exportJWK(kp.privateKey),
111
- exportJWK(kp.publicKey),
112
- ]);
113
- oauth2.keyPair = {privateKeyJwk, publicKeyJwk};
114
- delete oauth2.generateKeyPair;
115
- } else {
116
- // ensure key pair can be imported
117
- try {
118
- const {oauth2: {keyPair}} = openId;
119
- await Promise.all([
120
- importJWK(keyPair.privateKeyJwk),
121
- importJWK(keyPair.publicKeyJwk)
122
- ]);
123
- } catch(e) {
124
- throw new BedrockError(
125
- 'Could not import OpenID OAuth2 key pair.', {
126
- name: 'DataError',
127
- details: {httpStatusCode: 400, public: true},
128
- cause: e
129
- });
130
- }
131
- }
132
- }
133
-
134
- // insert exchange
135
- const {id: workflowId} = config;
136
- const exchange = {
137
- id: await generateRandom(),
138
- ttl,
139
- variables,
140
- openId,
141
- step
142
- };
143
- await exchanges.insert({workflowId, exchange});
144
- const location = `${workflowId}/exchanges/${exchange.id}`;
79
+ const exchange = {ttl, openId, variables, step};
80
+ const {id} = await createExchange({workflow, exchange});
81
+ const location = `${workflow.id}/exchanges/${id}`;
145
82
  res.status(204).location(location).send();
146
83
  } catch(error) {
147
84
  logger.error(error.message, {error});
@@ -180,7 +117,7 @@ export async function addRoutes({app, service} = {}) {
180
117
  await processExchange({req, res, workflow, exchange});
181
118
  }));
182
119
 
183
- // create OID4VCI routes to be used with each individual exchange
184
- await _openId.createRoutes(
120
+ // create OID4* routes to be used with each individual exchange
121
+ await oid4.createRoutes(
185
122
  {app, exchangeRoute: routes.exchange, getConfigMiddleware, getExchange});
186
123
  }
@@ -0,0 +1,327 @@
1
+ /*!
2
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as oid4vci from './oid4vci.js';
5
+ import * as oid4vp from './oid4vp.js';
6
+ import {
7
+ openIdAuthorizationResponseBody,
8
+ openIdBatchCredentialBody,
9
+ openIdCredentialBody,
10
+ openIdTokenBody
11
+ } from '../../schemas/bedrock-vc-workflow.js';
12
+ import {asyncHandler} from '@bedrock/express';
13
+ import bodyParser from 'body-parser';
14
+ import cors from 'cors';
15
+ import {UnsecuredJWT} from 'jose';
16
+ import {createValidateMiddleware as validate} from '@bedrock/validation';
17
+
18
+ /* NOTE: Parts of the OID4VCI design imply tight integration between the
19
+ authorization server and the credential issuance / delivery server. This
20
+ file provides the routes for both and treats them as integrated; supporting
21
+ the OID4VCI pre-authz code flow only as a result. However, we also try to
22
+ avoid tight-coupling where possible to enable the non-pre-authz code flow
23
+ that would use, somehow, a separate authorization server.
24
+
25
+ One tight coupling we try to avoid involves the option where the authorization
26
+ server generates the challenge nonce to be signed in a DID proof, but the
27
+ credential delivery server is the system responsible for checking and tracking
28
+ this challenge. The Credential Delivery server cannot know the challenge is
29
+ authentic without breaking some abstraction around how the Authorization
30
+ Server is implemented behind its API. Here we do not implement this option,
31
+ instead, if a challenge is required, the credential delivery server will send
32
+ an error with the challenge nonce if one was not provided in the payload to the
33
+ credential endpoint. This error follows the OID4VCI spec and avoids this
34
+ particular tight coupling.
35
+
36
+ Other tight couplings cannot be avoided at this time -- such as the fact that
37
+ the credential endpoint is specified in the authorization server's metadata;
38
+ this creates challenges for SaaS based solutions and for issuers that want to
39
+ use multiple different Issuance / Delivery server backends. We solve these
40
+ challenges by using the "pre-authorized code" flows and effectively
41
+ instantiating a new authorization server instance per VC exchange. */
42
+
43
+ // creates OID4VCI Authorization Server + Credential Delivery Server
44
+ // endpoints for each individual exchange
45
+ export async function createRoutes({
46
+ app, exchangeRoute, getConfigMiddleware, getExchange
47
+ } = {}) {
48
+ const openIdRoute = `${exchangeRoute}/openid`;
49
+ const routes = {
50
+ // OID4VCI routes
51
+ asMetadata1: `/.well-known/oauth-authorization-server${exchangeRoute}`,
52
+ asMetadata2: `${exchangeRoute}/.well-known/oauth-authorization-server`,
53
+ ciMetadata1: `/.well-known/openid-credential-issuer${exchangeRoute}`,
54
+ ciMetadata2: `${exchangeRoute}/.well-known/openid-credential-issuer`,
55
+ batchCredential: `${openIdRoute}/batch_credential`,
56
+ credential: `${openIdRoute}/credential`,
57
+ credentialOffer: `${openIdRoute}/credential-offer`,
58
+ token: `${openIdRoute}/token`,
59
+ jwks: `${openIdRoute}/jwks`,
60
+ // OID4VP routes
61
+ authorizationRequest: `${openIdRoute}/client/authorization/request`,
62
+ authorizationResponse: `${openIdRoute}/client/authorization/response`
63
+ };
64
+
65
+ // urlencoded body parser (extended=true for rich JSON-like representation)
66
+ const urlencoded = bodyParser.urlencoded({extended: true});
67
+
68
+ /* Note: The well-known metadata paths for the OID4VCI spec have been
69
+ specified in at least two different ways over time, including
70
+ `<path>/.well-known/...` and `/.well-known/.../<path>`, so they are provided
71
+ here using both approaches to maximize interoperability with clients. It
72
+ is also notable that some versions of the spec have indicated that the
73
+ credential issuer metadata should be expressed in the authorization server
74
+ metadata and others have indicated that they can be separate; since our
75
+ approach virtualizes both the AS and CI anyway, it is all served together. */
76
+
77
+ // an authorization server meta data endpoint
78
+ // serves `.well-known` oauth2 AS config for each exchange; each config is
79
+ // based on the workflow used to create the exchange
80
+ app.get(
81
+ routes.asMetadata1,
82
+ cors(),
83
+ getConfigMiddleware,
84
+ getExchange,
85
+ asyncHandler(async (req, res) => {
86
+ res.json(await oid4vci.getAuthorizationServerConfig({req}));
87
+ }));
88
+
89
+ // an authorization server meta data endpoint
90
+ // serves `.well-known` oauth2 AS config for each exchange; each config is
91
+ // based on the workflow used to create the exchange
92
+ app.get(
93
+ routes.asMetadata2,
94
+ cors(),
95
+ getConfigMiddleware,
96
+ getExchange,
97
+ asyncHandler(async (req, res) => {
98
+ res.json(await oid4vci.getAuthorizationServerConfig({req}));
99
+ }));
100
+
101
+ // a credential issuer meta data endpoint
102
+ // serves `.well-known` oauth2 AS / CI config for each exchange; each config
103
+ // is based on the workflow used to create the exchange
104
+ app.get(
105
+ routes.ciMetadata1,
106
+ cors(),
107
+ getConfigMiddleware,
108
+ getExchange,
109
+ asyncHandler(async (req, res) => {
110
+ res.json(await oid4vci.getCredentialIssuerConfig({req}));
111
+ }));
112
+
113
+ // a credential issuer meta data endpoint
114
+ // serves `.well-known` oauth2 AS / CI config for each exchange; each config
115
+ // is based on the workflow used to create the exchange
116
+ app.get(
117
+ routes.ciMetadata2,
118
+ cors(),
119
+ getConfigMiddleware,
120
+ getExchange,
121
+ asyncHandler(async (req, res) => {
122
+ res.json(await oid4vci.getCredentialIssuerConfig({req}));
123
+ }));
124
+
125
+ // an authorization server endpoint
126
+ // serves JWKs associated with each exchange; JWKs are stored with the
127
+ // workflow used to create the exchange
128
+ app.get(
129
+ routes.jwks,
130
+ cors(),
131
+ getExchange,
132
+ asyncHandler(async (req, res) => {
133
+ // serve exchange's public key(s)
134
+ const keys = await oid4vci.getJwks({req});
135
+ res.json({keys});
136
+ }));
137
+
138
+ // an authorization server endpoint
139
+ // handles pre-authorization code exchange for access token; only supports
140
+ // pre-authorization code grant type
141
+ app.options(routes.token, cors());
142
+ app.post(
143
+ routes.token,
144
+ cors(),
145
+ urlencoded,
146
+ validate({bodySchema: openIdTokenBody}),
147
+ getConfigMiddleware,
148
+ getExchange,
149
+ asyncHandler(async (req, res) => {
150
+ const response = await oid4vci.processAccessTokenRequest({req, res});
151
+ res.json(response);
152
+ }));
153
+
154
+ // a credential delivery server endpoint
155
+ // receives a credential request and returns VCs
156
+ app.options(routes.credential, cors());
157
+ app.post(
158
+ routes.credential,
159
+ cors(),
160
+ validate({bodySchema: openIdCredentialBody}),
161
+ getConfigMiddleware,
162
+ getExchange,
163
+ asyncHandler(async (req, res) => {
164
+ /* Clients must POST, e.g.:
165
+ POST /credential HTTP/1.1
166
+ Host: server.example.com
167
+ Content-Type: application/json
168
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
169
+
170
+ {
171
+ "format": "ldp_vc",
172
+ "credential_definition": {
173
+ "@context": [
174
+ "https://www.w3.org/2018/credentials/v1",
175
+ "https://www.w3.org/2018/credentials/examples/v1"
176
+ ],
177
+ "type": [
178
+ "VerifiableCredential",
179
+ "UniversityDegreeCredential"
180
+ ]
181
+ },
182
+ "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
183
+ "proof": {
184
+ "proof_type": "jwt",
185
+ "jwt": "eyJra...nOzM"
186
+ }
187
+ }
188
+ */
189
+ const result = await oid4vci.processCredentialRequests({
190
+ req, res, isBatchRequest: false
191
+ });
192
+ if(!result) {
193
+ // DID proof request response sent
194
+ return;
195
+ }
196
+
197
+ /* Note: The `/credential` route only supports sending a single VC;
198
+ assume here that this workflow is configured for a single VC and an
199
+ error code would have been sent to the client to use the batch
200
+ endpoint if there was more than one VC to deliver. */
201
+ const {response, format} = result;
202
+ const {verifiablePresentation: {verifiableCredential: [vc]}} = response;
203
+
204
+ // parse any enveloped VC
205
+ let credential;
206
+ if(vc.type === 'EnvelopedVerifiableCredential' &&
207
+ vc.id?.startsWith('data:application/jwt,')) {
208
+ credential = vc.id.slice('data:application/jwt,'.length);
209
+ } else {
210
+ credential = vc;
211
+ }
212
+
213
+ // send OID4VCI response
214
+ res.json({
215
+ // FIXME: this doesn't seem to be in the spec anymore (draft 14+)...
216
+ format,
217
+ credential
218
+ });
219
+ }));
220
+
221
+ // a credential delivery server endpoint
222
+ // serves the credential offer for all possible credentials in the exchange
223
+ app.get(
224
+ routes.credentialOffer,
225
+ cors(),
226
+ getConfigMiddleware,
227
+ getExchange,
228
+ asyncHandler(async (req, res) => {
229
+ const offer = await oid4vci.getCredentialOffer({req});
230
+ res.json(offer);
231
+ }));
232
+
233
+ // a batch credential delivery server endpoint
234
+ // receives N credential requests and returns N VCs
235
+ app.options(routes.batchCredential, cors());
236
+ app.post(
237
+ routes.batchCredential,
238
+ cors(),
239
+ validate({bodySchema: openIdBatchCredentialBody}),
240
+ getConfigMiddleware,
241
+ getExchange,
242
+ asyncHandler(async (req, res) => {
243
+ /* Clients must POST, e.g.:
244
+ POST /batch_credential HTTP/1.1
245
+ Host: server.example.com
246
+ Content-Type: application/json
247
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
248
+
249
+ {
250
+ credential_requests: [{
251
+ "format": "ldp_vc",
252
+ "credential_definition": {
253
+ "@context": [
254
+ "https://www.w3.org/2018/credentials/v1",
255
+ "https://www.w3.org/2018/credentials/examples/v1"
256
+ ],
257
+ "type": [
258
+ "VerifiableCredential",
259
+ "UniversityDegreeCredential"
260
+ ]
261
+ },
262
+ "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
263
+ "proof": {
264
+ "proof_type": "jwt",
265
+ "jwt": "eyJra...nOzM"
266
+ }
267
+ }]
268
+ }
269
+ */
270
+ const result = await oid4vci.processCredentialRequests({
271
+ req, res, isBatchRequest: true
272
+ });
273
+ if(!result) {
274
+ // DID proof request response sent
275
+ return;
276
+ }
277
+
278
+ // send VCs
279
+ const {response: {verifiablePresentation}, format} = result;
280
+ // FIXME: "format" doesn't seem to be in the spec anymore (draft 14+)...
281
+ const responses = verifiablePresentation.verifiableCredential.map(vc => {
282
+ // parse any enveloped VC
283
+ let credential;
284
+ if(vc.type === 'EnvelopedVerifiableCredential' &&
285
+ vc.id?.startsWith('data:application/jwt,')) {
286
+ credential = vc.id.slice('data:application/jwt,'.length);
287
+ } else {
288
+ credential = vc;
289
+ }
290
+ return {format, credential};
291
+ });
292
+ res.json({credential_responses: responses});
293
+ }));
294
+
295
+ // an OID4VP verifier endpoint
296
+ // serves the authorization request, including presentation definition
297
+ // associated with the current step in the exchange
298
+ app.get(
299
+ routes.authorizationRequest,
300
+ cors(),
301
+ getConfigMiddleware,
302
+ getExchange,
303
+ asyncHandler(async (req, res) => {
304
+ const {
305
+ authorizationRequest
306
+ } = await oid4vp.getAuthorizationRequest({req});
307
+ // construct and send authz request as unsecured JWT
308
+ const jwt = new UnsecuredJWT(authorizationRequest).encode();
309
+ res.set('content-type', 'application/oauth-authz-req+jwt');
310
+ res.send(jwt);
311
+ }));
312
+
313
+ // an OID4VP verifier endpoint
314
+ // receives an authorization response with vp_token
315
+ app.options(routes.authorizationResponse, cors());
316
+ app.post(
317
+ routes.authorizationResponse,
318
+ cors(),
319
+ urlencoded,
320
+ validate({bodySchema: openIdAuthorizationResponseBody()}),
321
+ getConfigMiddleware,
322
+ getExchange,
323
+ asyncHandler(async (req, res) => {
324
+ const result = await oid4vp.processAuthorizationResponse({req});
325
+ res.json(result);
326
+ }));
327
+ }