@blamejs/core 0.8.60 → 0.8.66
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/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +538 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +437 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -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
|
+
};
|