@blamejs/core 0.8.60 → 0.8.64

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.
@@ -0,0 +1,588 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.auth.oid4vci
4
+ * @nav Identity
5
+ * @title OpenID4VCI (issuer)
6
+ * @order 340
7
+ * @card OpenID for Verifiable Credential Issuance 1.0 — issuer side.
8
+ * Bridges OAuth2 token-endpoint output to a credential-
9
+ * issuance endpoint that mints SD-JWT VCs bound to the
10
+ * holder key supplied in the proof JWT.
11
+ *
12
+ * @intro
13
+ * The framework's SD-JWT VC primitive (`b.auth.sdJwtVc`) handles
14
+ * credential signing + sealed-claim disclosures. OID4VCI sits one
15
+ * layer above: it standardises HOW a wallet asks an issuer for a
16
+ * credential, and how the issuer announces what it can issue.
17
+ *
18
+ * This module ships the issuer-side glue (issuer-initiated +
19
+ * wallet-initiated flows):
20
+ *
21
+ * - credential_offer: issuer mints a one-shot offer +
22
+ * pre-authorized_code; emits a `openid-credential-offer://...`
23
+ * deep link the wallet scans / clicks.
24
+ * - /token (pre-authorized_code grant): holder POSTs the
25
+ * pre-auth code (+ optional tx_code) and gets an access token
26
+ * scoped to a specific credential identifier.
27
+ * - /credential: holder POSTs the access token + a `proof` JWT
28
+ * (signed by the holder key the wallet wants the credential
29
+ * bound to). The issuer mints + returns the SD-JWT VC with
30
+ * that key in `cnf`.
31
+ * - /.well-known/openid-credential-issuer: discovery metadata
32
+ * document describing supported credentials.
33
+ *
34
+ * The issuer composes:
35
+ * - `b.auth.sdJwtVc.issuer` for the actual SD-JWT VC minting
36
+ * - `b.cache` for the pre-auth code → user-binding map (TTL
37
+ * defaults to 5 minutes per OID4VCI §5.1.1)
38
+ * - `b.crypto.verify` for the holder proof-JWT signature
39
+ *
40
+ * Operators wire three routes (the framework gives the parsing +
41
+ * minting shape; HTTP-binding stays operator-side so the existing
42
+ * middleware stack — auth, rate-limit, CSRF — applies normally):
43
+ *
44
+ * POST /token → ciba-style /token shared with the OAuth
45
+ * client (or a separate handler that calls
46
+ * issuer.exchangePreAuthorizedCode)
47
+ * POST /credential → issuer.issueCredential(req)
48
+ * GET /.well-known/ → issuer.metadata()
49
+ * openid-credential-issuer
50
+ */
51
+
52
+ var C = require("../constants");
53
+ var lazyRequire = require("../lazy-require");
54
+ var validateOpts = require("../validate-opts");
55
+ var safeJson = require("../safe-json");
56
+ var nodeCrypto = require("node:crypto");
57
+ var { generateToken, sha3Hash } = require("../crypto");
58
+ var { AuthError } = require("../framework-error");
59
+
60
+ var cache = lazyRequire(function () { return require("../cache"); });
61
+ var audit = lazyRequire(function () { return require("../audit"); });
62
+ var observability = lazyRequire(function () { return require("../observability"); });
63
+ var emit = validateOpts.makeNamespacedEmitters("auth.oid4vci", { audit: audit, observability: observability });
64
+
65
+ var DEFAULT_PRE_AUTH_TTL_MS = C.TIME.minutes(5);
66
+ var DEFAULT_ACCESS_TOKEN_TTL = C.TIME.minutes(15);
67
+ var DEFAULT_C_NONCE_TTL_MS = C.TIME.minutes(5);
68
+ var MAX_PROOF_BYTES = 32 * 1024; // allow:raw-byte-literal — proof-JWT cap
69
+ var SUPPORTED_CREDENTIAL_FORMATS = ["vc+sd-jwt", "dc+sd-jwt"];
70
+
71
+ var _emitAudit = emit.audit;
72
+ var _emitMetric = emit.metric;
73
+
74
+ function _b64uDecodeStr(s) {
75
+ return Buffer.from(s, "base64url").toString("utf8");
76
+ }
77
+
78
+ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs) {
79
+ // OID4VCI §7.2.1.1: the proof JWT MUST:
80
+ // - typ = "openid4vci-proof+jwt"
81
+ // - alg in supported list (issuer publishes these)
82
+ // - aud = credential issuer URL (this issuer's `credential_issuer`)
83
+ // - iat = recent
84
+ // - nonce = c_nonce previously issued to the wallet
85
+ // - jwk OR kid in header pointing at the key to bind cnf to
86
+ if (typeof proofJwt !== "string" || proofJwt.length === 0 || proofJwt.length > MAX_PROOF_BYTES) {
87
+ throw new AuthError("auth-oid4vci/bad-proof",
88
+ "credential issuance: proof JWT is empty or exceeds " + MAX_PROOF_BYTES + " bytes");
89
+ }
90
+ var parts = proofJwt.split(".");
91
+ if (parts.length !== 3) {
92
+ throw new AuthError("auth-oid4vci/malformed-proof",
93
+ "credential issuance: proof JWT must have 3 dot-separated parts");
94
+ }
95
+ var header, payload;
96
+ try {
97
+ header = safeJson.parse(_b64uDecodeStr(parts[0]), { maxBytes: 4096 }); // allow:raw-byte-literal — proof header cap
98
+ payload = safeJson.parse(_b64uDecodeStr(parts[1]), { maxBytes: MAX_PROOF_BYTES });
99
+ } catch (e) {
100
+ throw new AuthError("auth-oid4vci/bad-proof-decode",
101
+ "credential issuance: proof JWT base64 decode failed: " + ((e && e.message) || String(e)));
102
+ }
103
+ if (header.typ !== "openid4vci-proof+jwt") {
104
+ throw new AuthError("auth-oid4vci/wrong-proof-typ",
105
+ "credential issuance: proof JWT typ must be \"openid4vci-proof+jwt\" (got \"" + header.typ + "\")");
106
+ }
107
+ if (!header.alg || supportedAlgs.indexOf(header.alg) === -1) {
108
+ throw new AuthError("auth-oid4vci/unsupported-proof-alg",
109
+ "credential issuance: proof JWT alg \"" + header.alg + "\" not in issuer-supported set");
110
+ }
111
+ if (!header.jwk && !header.kid && !header.x5c) {
112
+ throw new AuthError("auth-oid4vci/no-key-in-proof",
113
+ "credential issuance: proof JWT header must include `jwk`, `kid`, OR `x5c` (holder key binding)");
114
+ }
115
+ if (payload.aud !== expectedAud) {
116
+ throw new AuthError("auth-oid4vci/wrong-proof-aud",
117
+ "credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
118
+ }
119
+ if (expectedCNonce !== null && payload.nonce !== expectedCNonce) {
120
+ throw new AuthError("auth-oid4vci/wrong-proof-nonce",
121
+ "credential issuance: proof JWT nonce mismatch (replay defense — wallet must use the c_nonce from the most recent issuer response)");
122
+ }
123
+ if (typeof payload.iat !== "number") {
124
+ throw new AuthError("auth-oid4vci/no-proof-iat",
125
+ "credential issuance: proof JWT must include iat");
126
+ }
127
+ var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s
128
+ if (payload.iat > nowSec + 60) { // allow:raw-time-literal — 60s skew tolerance
129
+ throw new AuthError("auth-oid4vci/proof-iat-future",
130
+ "credential issuance: proof JWT iat is in the future");
131
+ }
132
+ if (payload.iat < nowSec - Math.floor(C.TIME.minutes(10) / 1000)) { // allow:raw-byte-literal — ms→s
133
+ throw new AuthError("auth-oid4vci/proof-iat-too-old",
134
+ "credential issuance: proof JWT iat older than 10 minutes — wallet must mint a fresh proof");
135
+ }
136
+ if (expectedClientId && payload.iss && payload.iss !== expectedClientId) {
137
+ throw new AuthError("auth-oid4vci/wrong-proof-iss",
138
+ "credential issuance: proof JWT iss does not match the access-token client_id");
139
+ }
140
+
141
+ // Verify the JWS signature using the key embedded in the header.
142
+ var holderKeyJwk = header.jwk || null;
143
+ if (!holderKeyJwk && header.kid) {
144
+ // Operators with kid-only proofs supply a resolver; until then,
145
+ // require jwk inline. Refuse rather than silently downgrade.
146
+ throw new AuthError("auth-oid4vci/kid-resolver-not-supported",
147
+ "credential issuance: proof JWT used `kid` without inline `jwk` — supply { jwk } in the header for inline binding (kid-resolver path is operator-side)");
148
+ }
149
+ if (!holderKeyJwk) {
150
+ throw new AuthError("auth-oid4vci/no-jwk-in-header",
151
+ "credential issuance: proof JWT must carry `jwk` for inline holder-key binding");
152
+ }
153
+ var keyObj;
154
+ try { keyObj = nodeCrypto.createPublicKey({ key: holderKeyJwk, format: "jwk" }); }
155
+ catch (e) {
156
+ throw new AuthError("auth-oid4vci/bad-jwk",
157
+ "credential issuance: proof JWT jwk is not parseable: " + ((e && e.message) || String(e)));
158
+ }
159
+
160
+ var signingInput = parts[0] + "." + parts[1];
161
+ var sig = Buffer.from(parts[2], "base64url");
162
+ // Map alg → hash + verify-options shape. ES256 = sha256+ieee-p1363,
163
+ // ES384 = sha384+ieee-p1363, EdDSA / RS256 / PS256 follow.
164
+ var hashByAlg = { ES256: "sha256", ES384: "sha384", ES512: "sha512", PS256: "sha256",
165
+ PS384: "sha384", PS512: "sha512", RS256: "sha256", RS384: "sha384",
166
+ RS512: "sha512", EdDSA: null };
167
+ if (!Object.prototype.hasOwnProperty.call(hashByAlg, header.alg)) {
168
+ throw new AuthError("auth-oid4vci/unsupported-proof-alg",
169
+ "credential issuance: proof JWT alg \"" + header.alg + "\" not in framework set");
170
+ }
171
+ var verifyOpts = { key: keyObj };
172
+ if (header.alg.indexOf("ES") === 0) verifyOpts.dsaEncoding = "ieee-p1363";
173
+ if (header.alg.indexOf("PS") === 0) {
174
+ verifyOpts.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING;
175
+ verifyOpts.saltLength = nodeCrypto.constants.RSA_PSS_SALTLEN_DIGEST;
176
+ }
177
+ var ok = nodeCrypto.verify(hashByAlg[header.alg], Buffer.from(signingInput, "ascii"), verifyOpts, sig);
178
+ if (!ok) {
179
+ throw new AuthError("auth-oid4vci/proof-bad-signature",
180
+ "credential issuance: proof JWT signature verification failed (holder doesn't actually hold the bound key)");
181
+ }
182
+ return { header: header, payload: payload, jwk: holderKeyJwk };
183
+ }
184
+
185
+ /**
186
+ * @primitive b.auth.oid4vci.issuer.create
187
+ * @signature b.auth.oid4vci.issuer.create(opts)
188
+ * @since 0.8.62
189
+ * @status stable
190
+ * @related b.auth.oid4vp.verifier.create, b.auth.ciba.client.create
191
+ *
192
+ * Build an OID4VCI issuer over a configured `b.auth.sdJwtVc.issuer`.
193
+ * Returns route handlers for credential_offer, /token (pre-authorized
194
+ * grant), and /credential, plus a `metadata()` accessor for the
195
+ * /.well-known/openid-credential-issuer document.
196
+ *
197
+ * @opts
198
+ * {
199
+ * credentialIssuerUrl: string, // required — used as `iss` and proof `aud`
200
+ * credentialEndpoint: string, // public URL for the /credential endpoint
201
+ * tokenEndpoint: string, // public URL for /token (re-used by the pre-auth flow)
202
+ * sdJwtIssuer: <b.auth.sdJwtVc.issuer instance>, // mints the SD-JWT VC
203
+ * supportedCredentials: { [id]: { format, vct, claims, ... } },
204
+ * proofAlgorithms: string[], // default ["ES256", "ES384"]
205
+ * preAuthCodeTtlMs?: number, // default 5m
206
+ * accessTokenTtlMs?: number, // default 15m
207
+ * cNonceTtlMs?: number, // default 5m
208
+ * codeStore?: b.cache instance,
209
+ * accessTokenStore?: b.cache instance,
210
+ * cNonceStore?: b.cache instance,
211
+ * }
212
+ *
213
+ * @example
214
+ * var sdJwtIssuer = b.auth.sdJwtVc.issuer.create({ issuerUrl: "https://issuer.example.com", keys: [{ kid: "k1", privateKey: pem, algorithm: "ES256" }] });
215
+ * var oid4vci = b.auth.oid4vci.issuer.create({
216
+ * credentialIssuerUrl: "https://issuer.example.com",
217
+ * credentialEndpoint: "https://issuer.example.com/credential",
218
+ * tokenEndpoint: "https://issuer.example.com/token",
219
+ * sdJwtIssuer: sdJwtIssuer,
220
+ * supportedCredentials: {
221
+ * "id-card-1": {
222
+ * format: "vc+sd-jwt",
223
+ * vct: "https://example.com/vct/identity",
224
+ * claims: { given_name: {}, family_name: {}, birthdate: {} },
225
+ * },
226
+ * },
227
+ * });
228
+ */
229
+ function create(opts) {
230
+ validateOpts.requireObject(opts, "auth.oid4vci.issuer.create", AuthError);
231
+ validateOpts.requireNonEmptyString(opts.credentialIssuerUrl,
232
+ "issuer.create: credentialIssuerUrl", AuthError, "auth-oid4vci/no-issuer-url");
233
+ validateOpts.requireNonEmptyString(opts.credentialEndpoint,
234
+ "issuer.create: credentialEndpoint", AuthError, "auth-oid4vci/no-credential-endpoint");
235
+ validateOpts.requireNonEmptyString(opts.tokenEndpoint,
236
+ "issuer.create: tokenEndpoint", AuthError, "auth-oid4vci/no-token-endpoint");
237
+ if (!opts.sdJwtIssuer || typeof opts.sdJwtIssuer.issue !== "function") {
238
+ throw new AuthError("auth-oid4vci/no-sd-jwt-issuer",
239
+ "issuer.create: sdJwtIssuer must be a b.auth.sdJwtVc.issuer instance");
240
+ }
241
+ if (!opts.supportedCredentials || typeof opts.supportedCredentials !== "object" ||
242
+ Object.keys(opts.supportedCredentials).length === 0) {
243
+ throw new AuthError("auth-oid4vci/no-supported-credentials",
244
+ "issuer.create: supportedCredentials must be a non-empty map of { id: { format, vct, ... } }");
245
+ }
246
+ Object.keys(opts.supportedCredentials).forEach(function (id) {
247
+ var spec = opts.supportedCredentials[id];
248
+ if (!spec || typeof spec !== "object") {
249
+ throw new AuthError("auth-oid4vci/bad-credential-spec",
250
+ "supportedCredentials['" + id + "'] must be an object");
251
+ }
252
+ if (!spec.format || SUPPORTED_CREDENTIAL_FORMATS.indexOf(spec.format) === -1) {
253
+ throw new AuthError("auth-oid4vci/unsupported-format",
254
+ "supportedCredentials['" + id + "'].format must be one of " + SUPPORTED_CREDENTIAL_FORMATS.join(", "));
255
+ }
256
+ if (typeof spec.vct !== "string" || spec.vct.length === 0) {
257
+ throw new AuthError("auth-oid4vci/no-vct",
258
+ "supportedCredentials['" + id + "'].vct is required");
259
+ }
260
+ });
261
+
262
+ var proofAlgs = Array.isArray(opts.proofAlgorithms) && opts.proofAlgorithms.length > 0
263
+ ? opts.proofAlgorithms : ["ES256", "ES384", "EdDSA"];
264
+
265
+ var preAuthTtl = opts.preAuthCodeTtlMs || DEFAULT_PRE_AUTH_TTL_MS;
266
+ var accessTokenTtl = opts.accessTokenTtlMs || DEFAULT_ACCESS_TOKEN_TTL;
267
+ var cNonceTtl = opts.cNonceTtlMs || DEFAULT_C_NONCE_TTL_MS;
268
+
269
+ var codeStore = opts.codeStore || cache().create({
270
+ namespace: "auth.oid4vci.preauth", ttlMs: preAuthTtl,
271
+ });
272
+ var atStore = opts.accessTokenStore || cache().create({
273
+ namespace: "auth.oid4vci.access_token", ttlMs: accessTokenTtl,
274
+ });
275
+ var cNonceStore = opts.cNonceStore || cache().create({
276
+ namespace: "auth.oid4vci.c_nonce", ttlMs: cNonceTtl,
277
+ });
278
+
279
+ /**
280
+ * @primitive b.auth.oid4vci.issuer.createCredentialOffer
281
+ * @signature b.auth.oid4vci.issuer.createCredentialOffer(opts)
282
+ * @since 0.8.62
283
+ *
284
+ * Mint a credential_offer + pre-authorized_code bound to a specific
285
+ * subject (the user the issuer has already authenticated out-of-
286
+ * band — kiosk, helpdesk identity proof, etc.). Returns the
287
+ * `openid-credential-offer://` deep link the wallet scans.
288
+ *
289
+ * @opts
290
+ * {
291
+ * subject: string,
292
+ * credentialIds: string[],
293
+ * txCode?: { value: string, length?: number, input_mode?: string, description?: string },
294
+ * }
295
+ *
296
+ * @example
297
+ * var offer = await oid4vci.createCredentialOffer({
298
+ * subject: "user-42",
299
+ * credentialIds: ["id-card-1"],
300
+ * });
301
+ * // → { offer, preAuthCode, deepLink, offerUri }
302
+ */
303
+ async function createCredentialOffer(coOpts) {
304
+ coOpts = coOpts || {};
305
+ if (typeof coOpts.subject !== "string" || coOpts.subject.length === 0) {
306
+ throw new AuthError("auth-oid4vci/no-subject",
307
+ "createCredentialOffer: subject is required");
308
+ }
309
+ if (!Array.isArray(coOpts.credentialIds) || coOpts.credentialIds.length === 0) {
310
+ throw new AuthError("auth-oid4vci/no-credential-ids",
311
+ "createCredentialOffer: credentialIds must be a non-empty array");
312
+ }
313
+ coOpts.credentialIds.forEach(function (id) {
314
+ if (!opts.supportedCredentials[id]) {
315
+ throw new AuthError("auth-oid4vci/unknown-credential-id",
316
+ "createCredentialOffer: credentialId \"" + id + "\" not in supportedCredentials");
317
+ }
318
+ });
319
+ var preAuthCode = generateToken(32); // allow:raw-byte-literal — 256-bit single-use pre-auth code
320
+ var txCode = coOpts.txCode || null;
321
+ if (txCode !== null) {
322
+ if (typeof txCode !== "object" || typeof txCode.value !== "string") {
323
+ throw new AuthError("auth-oid4vci/bad-tx-code",
324
+ "createCredentialOffer: txCode must be { value: string, length?, input_mode? }");
325
+ }
326
+ }
327
+ await codeStore.set(preAuthCode, {
328
+ subject: coOpts.subject,
329
+ credentialIds: coOpts.credentialIds.slice(),
330
+ txCodeHash: txCode ? sha3Hash("oid4vci-tx:" + txCode.value) : null,
331
+ issuedAt: Date.now(),
332
+ });
333
+ var offer = {
334
+ credential_issuer: opts.credentialIssuerUrl,
335
+ credential_configuration_ids: coOpts.credentialIds.slice(),
336
+ grants: {
337
+ "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
338
+ "pre-authorized_code": preAuthCode,
339
+ tx_code: txCode ? {
340
+ length: typeof txCode.length === "number" ? txCode.length : 4, // allow:raw-byte-literal — default tx-code 4 digits
341
+ input_mode: txCode.input_mode || "numeric",
342
+ description: txCode.description || undefined,
343
+ } : undefined,
344
+ },
345
+ },
346
+ };
347
+ var encoded = encodeURIComponent(JSON.stringify(offer));
348
+ _emitAudit("offer_created", "success", {
349
+ subject: coOpts.subject,
350
+ credentialIds: coOpts.credentialIds,
351
+ hasTxCode: !!txCode,
352
+ });
353
+ _emitMetric("offer-created");
354
+ return {
355
+ offer: offer,
356
+ preAuthCode: preAuthCode,
357
+ deepLink: "openid-credential-offer://?credential_offer=" + encoded,
358
+ offerUri: opts.credentialIssuerUrl + "/credential_offer/" + preAuthCode,
359
+ };
360
+ }
361
+
362
+ /**
363
+ * @primitive b.auth.oid4vci.issuer.exchangePreAuthorizedCode
364
+ * @signature b.auth.oid4vci.issuer.exchangePreAuthorizedCode(opts)
365
+ * @since 0.8.62
366
+ *
367
+ * Token-endpoint helper for the pre-authorized_code grant. Returns
368
+ * an access token + c_nonce the wallet uses on /credential. The
369
+ * underlying access token's scope is the credential_configuration_ids
370
+ * the offer was bound to.
371
+ *
372
+ * @opts
373
+ * {
374
+ * preAuthCode: string,
375
+ * txCode?: string,
376
+ * }
377
+ *
378
+ * @example
379
+ * var tokens = await oid4vci.exchangePreAuthorizedCode({
380
+ * preAuthCode: req.body["pre-authorized_code"],
381
+ * txCode: req.body.tx_code,
382
+ * });
383
+ * // → { access_token, token_type, expires_in, c_nonce, ... }
384
+ */
385
+ async function exchangePreAuthorizedCode(eopts) {
386
+ eopts = eopts || {};
387
+ if (typeof eopts.preAuthCode !== "string" || eopts.preAuthCode.length === 0) {
388
+ throw new AuthError("auth-oid4vci/missing-pre-auth-code",
389
+ "exchangePreAuthorizedCode: pre-authorized_code required");
390
+ }
391
+ var entry = await codeStore.get(eopts.preAuthCode);
392
+ if (!entry) {
393
+ throw new AuthError("auth-oid4vci/invalid-pre-auth-code",
394
+ "exchangePreAuthorizedCode: pre-authorized_code unknown / expired / already redeemed");
395
+ }
396
+ // Single-use: consume on success.
397
+ if (entry.txCodeHash !== null) {
398
+ if (typeof eopts.txCode !== "string" || eopts.txCode.length === 0) {
399
+ throw new AuthError("auth-oid4vci/missing-tx-code",
400
+ "exchangePreAuthorizedCode: tx_code required (offer mandates it)");
401
+ }
402
+ var txHash = sha3Hash("oid4vci-tx:" + eopts.txCode);
403
+ // Constant-time-ish compare via fixed-size sha3 hash equality.
404
+ if (txHash !== entry.txCodeHash) {
405
+ // Don't consume on failure — wallet may be retrying. Operator
406
+ // attaches their own attempt counter / lockout via b.auth.lockout.
407
+ _emitAudit("tx_code_mismatch", "failure", {
408
+ subject: entry.subject,
409
+ });
410
+ throw new AuthError("auth-oid4vci/tx-code-mismatch",
411
+ "exchangePreAuthorizedCode: tx_code does not match");
412
+ }
413
+ }
414
+ await codeStore.delete(eopts.preAuthCode);
415
+ var accessToken = generateToken(32); // allow:raw-byte-literal — 256-bit access token
416
+ var cNonce = generateToken(16); // allow:raw-byte-literal — 128-bit c_nonce
417
+ var record = {
418
+ subject: entry.subject,
419
+ credentialIds: entry.credentialIds,
420
+ cNonce: cNonce,
421
+ issuedAt: Date.now(),
422
+ };
423
+ await atStore.set(accessToken, record);
424
+ await cNonceStore.set(accessToken, cNonce);
425
+ _emitAudit("token_issued", "success", {
426
+ subject: entry.subject,
427
+ credentialIds: entry.credentialIds,
428
+ });
429
+ _emitMetric("token-issued");
430
+ return {
431
+ access_token: accessToken,
432
+ token_type: "Bearer",
433
+ expires_in: Math.floor(accessTokenTtl / 1000), // allow:raw-byte-literal — ms→s
434
+ c_nonce: cNonce,
435
+ c_nonce_expires_in: Math.floor(cNonceTtl / 1000), // allow:raw-byte-literal — ms→s
436
+ authorization_details: entry.credentialIds.map(function (id) {
437
+ return {
438
+ type: "openid_credential",
439
+ credential_configuration_id: id,
440
+ };
441
+ }),
442
+ };
443
+ }
444
+
445
+ /**
446
+ * @primitive b.auth.oid4vci.issuer.issueCredential
447
+ * @signature b.auth.oid4vci.issuer.issueCredential(opts)
448
+ * @since 0.8.62
449
+ *
450
+ * The /credential endpoint handler. Validates the access token,
451
+ * verifies the holder proof JWT (binding the holder key the wallet
452
+ * controls to a fresh c_nonce), mints the SD-JWT VC via the
453
+ * configured `sdJwtIssuer`, and rotates the c_nonce so the next
454
+ * request gets a fresh challenge. Returns the credential string +
455
+ * the new c_nonce.
456
+ *
457
+ * Operators supply `claims` per call (the issuer's own user-data
458
+ * lookup keyed off the access-token's subject); the framework
459
+ * doesn't store user attributes itself.
460
+ *
461
+ * @opts
462
+ * {
463
+ * accessToken: string,
464
+ * credentialIdentifier: string,
465
+ * proof: string, // openid4vci-proof+jwt
466
+ * claims: object, // operator-supplied user data
467
+ * selectivelyDisclosed?: string[],
468
+ * ttlMs?: number,
469
+ * }
470
+ *
471
+ * @example
472
+ * var rv = await oid4vci.issueCredential({
473
+ * accessToken: accessTokenFromBearerHeader,
474
+ * credentialIdentifier: "id-card-1",
475
+ * proof: req.body.proof.jwt,
476
+ * claims: { given_name: "Alice", family_name: "Smith" },
477
+ * });
478
+ * // → { format: "vc+sd-jwt", credential, c_nonce, c_nonce_expires_in }
479
+ */
480
+ async function issueCredential(iopts) {
481
+ iopts = iopts || {};
482
+ if (typeof iopts.accessToken !== "string" || iopts.accessToken.length === 0) {
483
+ throw new AuthError("auth-oid4vci/missing-access-token",
484
+ "issueCredential: accessToken required");
485
+ }
486
+ var record = await atStore.get(iopts.accessToken);
487
+ if (!record) {
488
+ throw new AuthError("auth-oid4vci/invalid-access-token",
489
+ "issueCredential: access token unknown / expired");
490
+ }
491
+ if (typeof iopts.credentialIdentifier !== "string" ||
492
+ record.credentialIds.indexOf(iopts.credentialIdentifier) === -1) {
493
+ throw new AuthError("auth-oid4vci/wrong-credential-identifier",
494
+ "issueCredential: credentialIdentifier not in this access-token's authorized set");
495
+ }
496
+ var spec = opts.supportedCredentials[iopts.credentialIdentifier];
497
+ if (!spec) {
498
+ throw new AuthError("auth-oid4vci/unknown-credential-id",
499
+ "issueCredential: credentialIdentifier not configured");
500
+ }
501
+
502
+ var expectedCNonce = await cNonceStore.get(iopts.accessToken);
503
+ var verified = _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs);
504
+
505
+ if (!iopts.claims || typeof iopts.claims !== "object") {
506
+ throw new AuthError("auth-oid4vci/no-claims",
507
+ "issueCredential: claims required (operator looks up the subject's data and supplies them)");
508
+ }
509
+ var sdJwtToken = await opts.sdJwtIssuer.issue({
510
+ vct: spec.vct,
511
+ subject: record.subject,
512
+ claims: iopts.claims,
513
+ selectivelyDisclosed: iopts.selectivelyDisclosed || Object.keys(iopts.claims),
514
+ holderKey: verified.jwk,
515
+ ttlMs: iopts.ttlMs,
516
+ });
517
+
518
+ // Rotate c_nonce so a replayed proof-JWT for a follow-up
519
+ // batch_credential request is rejected.
520
+ var newCNonce = generateToken(16); // allow:raw-byte-literal — 128-bit c_nonce
521
+ await cNonceStore.set(iopts.accessToken, newCNonce);
522
+
523
+ _emitAudit("credential_issued", "success", {
524
+ subject: record.subject,
525
+ credentialIdentifier: iopts.credentialIdentifier,
526
+ vct: spec.vct,
527
+ });
528
+ _emitMetric("credential-issued");
529
+
530
+ return {
531
+ format: spec.format,
532
+ credential: sdJwtToken.token,
533
+ c_nonce: newCNonce,
534
+ c_nonce_expires_in: Math.floor(cNonceTtl / 1000), // allow:raw-byte-literal — ms→s
535
+ };
536
+ }
537
+
538
+ /**
539
+ * @primitive b.auth.oid4vci.issuer.metadata
540
+ * @signature b.auth.oid4vci.issuer.metadata()
541
+ * @since 0.8.62
542
+ *
543
+ * Returns the /.well-known/openid-credential-issuer JSON document
544
+ * describing the issuer's supported credentials, endpoints, and
545
+ * proof types. Operators serve the result verbatim.
546
+ *
547
+ * @example
548
+ * app.get("/.well-known/openid-credential-issuer", function (req, res) {
549
+ * res.setHeader("Content-Type", "application/json");
550
+ * res.end(JSON.stringify(oid4vci.metadata()));
551
+ * });
552
+ */
553
+ function metadata() {
554
+ var configurations = {};
555
+ Object.keys(opts.supportedCredentials).forEach(function (id) {
556
+ var spec = opts.supportedCredentials[id];
557
+ configurations[id] = {
558
+ format: spec.format,
559
+ vct: spec.vct,
560
+ claims: spec.claims || {},
561
+ cryptographic_binding_methods_supported: spec.cryptographic_binding_methods_supported || ["jwk"],
562
+ credential_signing_alg_values_supported: spec.credential_signing_alg_values_supported || ["ES256"],
563
+ proof_types_supported: {
564
+ jwt: { proof_signing_alg_values_supported: proofAlgs },
565
+ },
566
+ display: spec.display || undefined,
567
+ };
568
+ });
569
+ return {
570
+ credential_issuer: opts.credentialIssuerUrl,
571
+ credential_endpoint: opts.credentialEndpoint,
572
+ token_endpoint: opts.tokenEndpoint,
573
+ authorization_servers: opts.authorizationServers || [opts.credentialIssuerUrl],
574
+ credential_configurations_supported: configurations,
575
+ };
576
+ }
577
+
578
+ return {
579
+ createCredentialOffer: createCredentialOffer,
580
+ exchangePreAuthorizedCode: exchangePreAuthorizedCode,
581
+ issueCredential: issueCredential,
582
+ metadata: metadata,
583
+ };
584
+ }
585
+
586
+ module.exports = {
587
+ issuer: { create: create },
588
+ };