@blamejs/core 0.7.63 → 0.7.73

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,512 @@
1
+ "use strict";
2
+ /**
3
+ * DPoP — Demonstrating Proof of Possession (RFC 9449).
4
+ *
5
+ * A DPoP proof is a short-lived JWT signed by an ephemeral keypair the
6
+ * client holds. The client embeds the public half (`jwk` header) in the
7
+ * proof and signs over the request method + URI + a per-request `jti`,
8
+ * optionally binding the proof to the access token (`ath` claim) and a
9
+ * server-issued nonce.
10
+ *
11
+ * The server verifies the proof against the embedded public key and
12
+ * checks: typ / alg / iat freshness / htm / htu / jti uniqueness within
13
+ * the freshness window / optional ath against the presented access
14
+ * token / optional nonce / optional thumbprint match.
15
+ *
16
+ * Defends against captured-bearer-token theft: an attacker who exfils
17
+ * the access token still can't replay it without also stealing the
18
+ * client's private key (which never leaves the client).
19
+ *
20
+ * Surface:
21
+ *
22
+ * await b.auth.dpop.buildProof(opts) // → string (compact JWS)
23
+ * await b.auth.dpop.verify(proof, opts) // → { header, payload, jkt }
24
+ * b.auth.dpop.thumbprint(jwk) // → base64url-sha256(canonical-jwk)
25
+ *
26
+ * Middleware: see `lib/middleware/dpop.js` (`b.middleware.dpop`).
27
+ */
28
+
29
+ var nodeCrypto = require("crypto");
30
+ var blamejsCrypto = require("../crypto");
31
+ var safeJson = require("../safe-json");
32
+ var safeUrl = require("../safe-url");
33
+ var validateOpts = require("../validate-opts");
34
+ var C = require("../constants");
35
+ var { AuthError } = require("../framework-error");
36
+
37
+ // ---- constants ----
38
+
39
+ var DEFAULT_IAT_WINDOW_SEC = C.TIME.minutes(1) / C.TIME.seconds(1); // 60 seconds
40
+ // SLH-DSA-SHAKE-256f signatures alone are ~50 KB before base64url +
41
+ // the embedded JWK; the cap accommodates the worst-case PQC alg, with
42
+ // margin. Operators emitting only classical-alg proofs see proofs in
43
+ // the ~500-byte range.
44
+ var MAX_PROOF_BYTES = C.BYTES.kib(96);
45
+
46
+ // Classical asymmetric algs for DPoP (RFC 9449 §4.2).
47
+ var SUPPORTED_CLASSICAL_ALGS = [
48
+ "ES256", "ES384", "ES512",
49
+ "PS256", "PS384", "PS512",
50
+ "RS256", "RS384", "RS512",
51
+ "EdDSA",
52
+ ];
53
+
54
+ // PQC algs the framework accepts in DPoP proofs. ML-DSA-87 is the
55
+ // throughput-friendly option; SLH-DSA-SHAKE-256f is intentionally
56
+ // omitted because Node does not currently support JWK
57
+ // import/export for SLH-DSA (and DPoP requires the public key embedded
58
+ // as a JWK in the proof header). Re-add to this list when Node exposes
59
+ // SLH-DSA JWK round-trip; SLH-DSA's ~50 KB signatures + ~80x sign-time
60
+ // penalty also make it a poor fit for per-request DPoP proofs.
61
+ var SUPPORTED_PQC_ALGS = [
62
+ "ML-DSA-87",
63
+ ];
64
+
65
+ var SUPPORTED_ALGS = SUPPORTED_CLASSICAL_ALGS.concat(SUPPORTED_PQC_ALGS);
66
+
67
+ // HMAC + "none" are NEVER accepted in DPoP. HMAC requires a shared
68
+ // secret which DPoP's embedded-jwk model can't supply; "none" defeats
69
+ // the entire proof.
70
+ var REFUSED_ALGS = ["HS256", "HS384", "HS512", "none"];
71
+
72
+ // ---- helpers ----
73
+
74
+ function _b64urlEncode(buf) {
75
+ if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
76
+ return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
77
+ }
78
+
79
+ function _b64urlDecode(s) {
80
+ if (typeof s !== "string") {
81
+ throw new AuthError("auth-dpop/bad-base64", "expected base64url string");
82
+ }
83
+ var padded = s.replace(/-/g, "+").replace(/_/g, "/");
84
+ while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
85
+ return Buffer.from(padded, "base64");
86
+ }
87
+
88
+ // Canonical JWK per RFC 7638 — keys present in lexicographic order,
89
+ // only the kty-defined "required" members. Used for thumbprint.
90
+ function _canonicalJwk(jwk) {
91
+ if (!jwk || typeof jwk !== "object") {
92
+ throw new AuthError("auth-dpop/bad-jwk", "jwk must be an object");
93
+ }
94
+ if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
95
+ throw new AuthError("auth-dpop/bad-jwk", "jwk.kty is required");
96
+ }
97
+ if (jwk.kty === "EC") {
98
+ if (typeof jwk.crv !== "string" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
99
+ throw new AuthError("auth-dpop/bad-jwk", "EC jwk requires crv, x, y");
100
+ }
101
+ return JSON.stringify({ crv: jwk.crv, kty: "EC", x: jwk.x, y: jwk.y });
102
+ }
103
+ if (jwk.kty === "OKP") {
104
+ if (typeof jwk.crv !== "string" || typeof jwk.x !== "string") {
105
+ throw new AuthError("auth-dpop/bad-jwk", "OKP jwk requires crv, x");
106
+ }
107
+ return JSON.stringify({ crv: jwk.crv, kty: "OKP", x: jwk.x });
108
+ }
109
+ if (jwk.kty === "RSA") {
110
+ if (typeof jwk.e !== "string" || typeof jwk.n !== "string") {
111
+ throw new AuthError("auth-dpop/bad-jwk", "RSA jwk requires e, n");
112
+ }
113
+ return JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n });
114
+ }
115
+ if (jwk.kty === "AKP") {
116
+ // PQC asymmetric key package (draft-ietf-cose-cnsa-pqc / IANA AKP
117
+ // registry). Node:crypto exports ML-DSA / SLH-DSA public keys with
118
+ // kty=AKP, alg=<algId>, pub=<base64url public bytes>.
119
+ if (typeof jwk.alg !== "string" || typeof jwk.pub !== "string") {
120
+ throw new AuthError("auth-dpop/bad-jwk", "AKP jwk requires alg, pub");
121
+ }
122
+ return JSON.stringify({ alg: jwk.alg, kty: "AKP", pub: jwk.pub });
123
+ }
124
+ // Symmetric keys (oct) and any other kty are refused outright — DPoP's
125
+ // proof model requires asymmetric.
126
+ throw new AuthError("auth-dpop/refused-kty",
127
+ "jwk.kty='" + jwk.kty + "' is not allowed (DPoP requires asymmetric kty)");
128
+ }
129
+
130
+ function thumbprint(jwk) {
131
+ var canonical = _canonicalJwk(jwk);
132
+ var hash = nodeCrypto.createHash("sha256").update(canonical, "utf8").digest();
133
+ return _b64urlEncode(hash);
134
+ }
135
+
136
+ function _sha256B64Url(input) {
137
+ var hash = nodeCrypto.createHash("sha256").update(input, "utf8").digest();
138
+ return _b64urlEncode(hash);
139
+ }
140
+
141
+ // Strip query + fragment from htu per RFC 9449 §4.3 step 5.
142
+ function _normalizeHtu(htu) {
143
+ if (typeof htu !== "string" || htu.length === 0) {
144
+ throw new AuthError("auth-dpop/bad-htu", "htu must be a non-empty string");
145
+ }
146
+ // Use safeUrl to validate the URL itself (refuses control bytes,
147
+ // unsupported schemes, etc.). Then strip query + fragment.
148
+ var parsed;
149
+ try { parsed = safeUrl.parse(htu, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS }); }
150
+ catch (e) {
151
+ throw new AuthError("auth-dpop/bad-htu",
152
+ "htu parse failed: " + ((e && e.message) || String(e)));
153
+ }
154
+ // Reconstruct origin + path; safeUrl exposes the parsed pieces.
155
+ var port = (parsed.port && parsed.port.length > 0) ? (":" + parsed.port) : "";
156
+ return parsed.protocol + "//" + parsed.hostname + port + (parsed.pathname || "/");
157
+ }
158
+
159
+ // Pick alg-specific node:crypto verify params. PQC algs use
160
+ // signWithoutAlgorithm shape (`null` algorithm).
161
+ function _signParamsForAlg(alg) {
162
+ if (alg === "RS256") return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
163
+ if (alg === "RS384") return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
164
+ if (alg === "RS512") return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
165
+ if (alg === "PS256") return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }; // allow:raw-byte-literal — RFC 7518 PS256 salt length
166
+ if (alg === "PS384") return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 48 }; // allow:raw-byte-literal — RFC 7518 PS384 salt length
167
+ if (alg === "PS512") return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 64 }; // allow:raw-byte-literal — RFC 7518 PS512 salt length
168
+ if (alg === "ES256") return { hash: "sha256", dsaEncoding: "ieee-p1363" };
169
+ if (alg === "ES384") return { hash: "sha384", dsaEncoding: "ieee-p1363" };
170
+ if (alg === "ES512") return { hash: "sha512", dsaEncoding: "ieee-p1363" };
171
+ if (alg === "EdDSA") return { hash: null };
172
+ if (alg === "ML-DSA-87") return { hash: null, pqc: true };
173
+ throw new AuthError("auth-dpop/unsupported-alg",
174
+ "alg '" + alg + "' is not supported by DPoP");
175
+ }
176
+
177
+ function _toPrivateKey(value) {
178
+ if (!value) {
179
+ throw new AuthError("auth-dpop/missing-private-key",
180
+ "buildProof: privateKey is required");
181
+ }
182
+ if (value instanceof nodeCrypto.KeyObject) return value;
183
+ if (typeof value === "string" || Buffer.isBuffer(value)) {
184
+ try { return nodeCrypto.createPrivateKey({ key: value, format: "pem" }); }
185
+ catch (e) {
186
+ throw new AuthError("auth-dpop/bad-private-key",
187
+ "PEM parse failed: " + ((e && e.message) || String(e)));
188
+ }
189
+ }
190
+ if (typeof value === "object" && value.kty) {
191
+ try { return nodeCrypto.createPrivateKey({ key: value, format: "jwk" }); }
192
+ catch (e) {
193
+ throw new AuthError("auth-dpop/bad-private-key",
194
+ "JWK parse failed: " + ((e && e.message) || String(e)));
195
+ }
196
+ }
197
+ throw new AuthError("auth-dpop/bad-private-key",
198
+ "privateKey must be PEM string/Buffer, JWK object, or KeyObject");
199
+ }
200
+
201
+ function _publicJwkFromPrivate(privateKey) {
202
+ // Export the public half as a JWK so we can embed it in the header.
203
+ var pub = nodeCrypto.createPublicKey(privateKey);
204
+ try { return pub.export({ format: "jwk" }); }
205
+ catch (e) {
206
+ throw new AuthError("auth-dpop/bad-private-key",
207
+ "could not derive public JWK: " + ((e && e.message) || String(e)));
208
+ }
209
+ }
210
+
211
+ function _detectAlgFromKey(key) {
212
+ // Best-effort algorithm detection from the key type. Operators can
213
+ // override via opts.algorithm.
214
+ var t = key.asymmetricKeyType;
215
+ var details = key.asymmetricKeyDetails || {};
216
+ if (t === "ec" && details.namedCurve === "prime256v1") return "ES256";
217
+ if (t === "ec" && details.namedCurve === "secp384r1") return "ES384";
218
+ if (t === "ec" && details.namedCurve === "secp521r1") return "ES512";
219
+ if (t === "ed25519" || t === "ed448") return "EdDSA";
220
+ if (t === "rsa" || t === "rsa-pss") return "RS256";
221
+ if (t === "ml-dsa-87") return "ML-DSA-87";
222
+ throw new AuthError("auth-dpop/unsupported-key",
223
+ "could not infer DPoP alg from key type='" + t + "' " +
224
+ "(SLH-DSA is not currently supported in DPoP — Node lacks SLH-DSA " +
225
+ "JWK round-trip; use ML-DSA-87 for PQC-DPoP)");
226
+ }
227
+
228
+ function _jwkToKeyObject(jwk) {
229
+ try { return nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }
230
+ catch (e) {
231
+ throw new AuthError("auth-dpop/bad-jwk",
232
+ "could not import jwk: " + ((e && e.message) || String(e)));
233
+ }
234
+ }
235
+
236
+ // ---- buildProof ----
237
+
238
+ async function buildProof(opts) {
239
+ opts = opts || {};
240
+ validateOpts(opts, [
241
+ "htm", "htu", "privateKey", "algorithm", "accessToken", "nonce", "jti", "iat", "jwk",
242
+ ], "auth.dpop.buildProof");
243
+
244
+ validateOpts.requireNonEmptyString(opts.htm,
245
+ "buildProof: htm (HTTP method)", AuthError, "auth-dpop/bad-htm");
246
+ validateOpts.requireNonEmptyString(opts.htu,
247
+ "buildProof: htu (request URI)", AuthError, "auth-dpop/bad-htu");
248
+ var key = _toPrivateKey(opts.privateKey);
249
+ var alg = opts.algorithm || _detectAlgFromKey(key);
250
+ if (REFUSED_ALGS.indexOf(alg) !== -1) {
251
+ throw new AuthError("auth-dpop/refused-alg",
252
+ "alg '" + alg + "' is refused by DPoP (HMAC/none)");
253
+ }
254
+ if (SUPPORTED_ALGS.indexOf(alg) === -1) {
255
+ throw new AuthError("auth-dpop/unsupported-alg",
256
+ "alg '" + alg + "' is not supported by DPoP");
257
+ }
258
+
259
+ var jwk = opts.jwk || _publicJwkFromPrivate(key);
260
+ // Strip private parts from the embedded jwk if the operator passed a
261
+ // private JWK by accident — ONLY public components belong in the proof.
262
+ var pubJwk;
263
+ if (jwk.kty === "EC") pubJwk = { kty: "EC", crv: jwk.crv, x: jwk.x, y: jwk.y };
264
+ else if (jwk.kty === "OKP") pubJwk = { kty: "OKP", crv: jwk.crv, x: jwk.x };
265
+ else if (jwk.kty === "RSA") pubJwk = { kty: "RSA", e: jwk.e, n: jwk.n };
266
+ else if (jwk.kty === "AKP") pubJwk = { kty: "AKP", alg: jwk.alg, pub: jwk.pub };
267
+ else throw new AuthError("auth-dpop/refused-kty",
268
+ "jwk.kty='" + jwk.kty + "' is not allowed");
269
+
270
+ var jti = opts.jti || _b64urlEncode(nodeCrypto.randomBytes(C.BYTES.bytes(16)));
271
+ var nowMs = (typeof opts.iat === "number" ? opts.iat * C.TIME.seconds(1) : Date.now());
272
+ var iatSec = Math.floor(nowMs / C.TIME.seconds(1));
273
+
274
+ var header = { typ: "dpop+jwt", alg: alg, jwk: pubJwk };
275
+ var payload = {
276
+ jti: jti,
277
+ htm: opts.htm.toUpperCase(),
278
+ htu: _normalizeHtu(opts.htu),
279
+ iat: iatSec,
280
+ };
281
+ if (typeof opts.accessToken === "string" && opts.accessToken.length > 0) {
282
+ payload.ath = _sha256B64Url(opts.accessToken);
283
+ }
284
+ if (typeof opts.nonce === "string" && opts.nonce.length > 0) {
285
+ payload.nonce = opts.nonce;
286
+ }
287
+
288
+ var headerB64 = _b64urlEncode(JSON.stringify(header));
289
+ var payloadB64 = _b64urlEncode(JSON.stringify(payload));
290
+ var signingInput = headerB64 + "." + payloadB64;
291
+
292
+ var params = _signParamsForAlg(alg);
293
+ var sig;
294
+ if (params.pqc) {
295
+ sig = nodeCrypto.sign(null, Buffer.from(signingInput, "ascii"), key);
296
+ } else if (params.hash === null) {
297
+ sig = nodeCrypto.sign(null, Buffer.from(signingInput, "ascii"), key);
298
+ } else {
299
+ var keyParam = { key: key };
300
+ if (params.padding !== undefined) keyParam.padding = params.padding;
301
+ if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
302
+ if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
303
+ sig = nodeCrypto.sign(params.hash, Buffer.from(signingInput, "ascii"), keyParam);
304
+ }
305
+
306
+ return signingInput + "." + _b64urlEncode(sig);
307
+ }
308
+
309
+ // ---- verify ----
310
+
311
+ async function verify(proof, opts) {
312
+ if (typeof proof !== "string" || proof.length === 0) {
313
+ throw new AuthError("auth-dpop/no-proof", "DPoP proof must be a non-empty string");
314
+ }
315
+ if (proof.length > MAX_PROOF_BYTES) {
316
+ throw new AuthError("auth-dpop/proof-too-large",
317
+ "DPoP proof exceeds " + MAX_PROOF_BYTES + " bytes");
318
+ }
319
+ opts = opts || {};
320
+ validateOpts(opts, [
321
+ "htm", "htu", "algorithms", "iatWindowSec", "accessToken",
322
+ "expectedThumbprint", "nonce", "replayStore", "now",
323
+ ], "auth.dpop.verify");
324
+
325
+ validateOpts.requireNonEmptyString(opts.htm,
326
+ "verify: opts.htm (expected HTTP method)", AuthError, "auth-dpop/bad-htm");
327
+ validateOpts.requireNonEmptyString(opts.htu,
328
+ "verify: opts.htu (expected request URI)", AuthError, "auth-dpop/bad-htu");
329
+
330
+ var allowed = (Array.isArray(opts.algorithms) && opts.algorithms.length > 0)
331
+ ? opts.algorithms : SUPPORTED_ALGS;
332
+ for (var ai = 0; ai < allowed.length; ai += 1) {
333
+ if (REFUSED_ALGS.indexOf(allowed[ai]) !== -1) {
334
+ throw new AuthError("auth-dpop/refused-alg",
335
+ "alg '" + allowed[ai] + "' is refused by DPoP");
336
+ }
337
+ if (SUPPORTED_ALGS.indexOf(allowed[ai]) === -1) {
338
+ throw new AuthError("auth-dpop/unsupported-alg",
339
+ "alg '" + allowed[ai] + "' is not supported (supported: " +
340
+ SUPPORTED_ALGS.join(", ") + ")");
341
+ }
342
+ }
343
+
344
+ var iatWindowSec = (typeof opts.iatWindowSec === "number" ? opts.iatWindowSec : DEFAULT_IAT_WINDOW_SEC);
345
+ if (!isFinite(iatWindowSec) || iatWindowSec <= 0) {
346
+ throw new AuthError("auth-dpop/bad-iat-window",
347
+ "iatWindowSec must be a positive finite number");
348
+ }
349
+
350
+ var parts = proof.split(".");
351
+ if (parts.length !== 3) {
352
+ throw new AuthError("auth-dpop/malformed", "proof must have 3 dot-separated parts");
353
+ }
354
+ var header, payload;
355
+ try { header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8")); }
356
+ catch (_e) { throw new AuthError("auth-dpop/malformed", "header is not valid base64url-JSON"); }
357
+ try { payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8")); }
358
+ catch (_e) { throw new AuthError("auth-dpop/malformed", "payload is not valid base64url-JSON"); }
359
+
360
+ // Header checks
361
+ if (header.typ !== "dpop+jwt") {
362
+ throw new AuthError("auth-dpop/bad-typ",
363
+ "header.typ must be 'dpop+jwt' (got " + JSON.stringify(header.typ) + ")");
364
+ }
365
+ if (typeof header.alg !== "string") {
366
+ throw new AuthError("auth-dpop/malformed", "header.alg is required");
367
+ }
368
+ if (allowed.indexOf(header.alg) === -1) {
369
+ throw new AuthError("auth-dpop/alg-not-allowed",
370
+ "alg '" + header.alg + "' not in allowed list [" + allowed.join(", ") + "]");
371
+ }
372
+ if (!header.jwk || typeof header.jwk !== "object") {
373
+ throw new AuthError("auth-dpop/missing-jwk",
374
+ "header.jwk is required (DPoP proof embeds the public key)");
375
+ }
376
+ // Refuse private-half-leak in the header (the proof must not embed
377
+ // the private key — RFC 9449 §4.2 only public parameters).
378
+ if (header.jwk.d !== undefined || header.jwk.p !== undefined ||
379
+ header.jwk.q !== undefined || header.jwk.dp !== undefined ||
380
+ header.jwk.dq !== undefined || header.jwk.qi !== undefined ||
381
+ header.jwk.k !== undefined || header.jwk.priv !== undefined) {
382
+ throw new AuthError("auth-dpop/jwk-has-private",
383
+ "header.jwk contains private-key components — refused");
384
+ }
385
+ if (header.crit !== undefined) {
386
+ throw new AuthError("auth-dpop/unknown-crit",
387
+ "DPoP proof declares 'crit' header — refused");
388
+ }
389
+
390
+ // Verify signature against the embedded jwk
391
+ var key = _jwkToKeyObject(header.jwk);
392
+ var params = _signParamsForAlg(header.alg);
393
+ var signingInput = parts[0] + "." + parts[1];
394
+ var sigBuf;
395
+ try { sigBuf = _b64urlDecode(parts[2]); }
396
+ catch (_e) { throw new AuthError("auth-dpop/malformed", "signature is not valid base64url"); }
397
+
398
+ var verified = false;
399
+ try {
400
+ if (params.pqc || params.hash === null) {
401
+ verified = nodeCrypto.verify(null, Buffer.from(signingInput, "ascii"), key, sigBuf);
402
+ } else {
403
+ var keyParam = { key: key };
404
+ if (params.padding !== undefined) keyParam.padding = params.padding;
405
+ if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
406
+ if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
407
+ verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), keyParam, sigBuf);
408
+ }
409
+ } catch (e) {
410
+ throw new AuthError("auth-dpop/invalid-signature",
411
+ "signature verification failed: " + ((e && e.message) || String(e)));
412
+ }
413
+ if (!verified) {
414
+ throw new AuthError("auth-dpop/invalid-signature", "signature verification failed");
415
+ }
416
+
417
+ // Compute thumbprint for downstream binding (jkt → access-token cnf claim)
418
+ var jkt = thumbprint(header.jwk);
419
+ if (typeof opts.expectedThumbprint === "string" && opts.expectedThumbprint.length > 0) {
420
+ if (!blamejsCrypto.timingSafeEqual(jkt, opts.expectedThumbprint)) {
421
+ throw new AuthError("auth-dpop/thumbprint-mismatch",
422
+ "proof key thumbprint does not match expected");
423
+ }
424
+ }
425
+
426
+ // Payload checks
427
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) {
428
+ throw new AuthError("auth-dpop/missing-jti", "payload.jti is required");
429
+ }
430
+ if (typeof payload.htm !== "string" || payload.htm.length === 0) {
431
+ throw new AuthError("auth-dpop/bad-htm", "payload.htm is required");
432
+ }
433
+ if (payload.htm.toUpperCase() !== opts.htm.toUpperCase()) {
434
+ throw new AuthError("auth-dpop/htm-mismatch",
435
+ "payload.htm='" + payload.htm + "' does not match expected '" + opts.htm + "'");
436
+ }
437
+ if (typeof payload.htu !== "string" || payload.htu.length === 0) {
438
+ throw new AuthError("auth-dpop/bad-htu", "payload.htu is required");
439
+ }
440
+ var expectedHtu = _normalizeHtu(opts.htu);
441
+ var actualHtu = _normalizeHtu(payload.htu);
442
+ if (actualHtu !== expectedHtu) {
443
+ throw new AuthError("auth-dpop/htu-mismatch",
444
+ "payload.htu='" + actualHtu + "' does not match expected '" + expectedHtu + "'");
445
+ }
446
+ if (typeof payload.iat !== "number" || !isFinite(payload.iat)) {
447
+ throw new AuthError("auth-dpop/bad-iat",
448
+ "payload.iat must be a finite number (RFC 7519 NumericDate)");
449
+ }
450
+ var nowMs = (typeof opts.now === "number" ? opts.now : Date.now());
451
+ var nowSec = Math.floor(nowMs / C.TIME.seconds(1));
452
+ if (Math.abs(nowSec - payload.iat) > iatWindowSec) {
453
+ throw new AuthError("auth-dpop/iat-out-of-window",
454
+ "payload.iat=" + payload.iat + " outside ±" + iatWindowSec + "s of now=" + nowSec);
455
+ }
456
+
457
+ // ath — when caller supplies accessToken, payload MUST carry matching ath
458
+ if (typeof opts.accessToken === "string" && opts.accessToken.length > 0) {
459
+ var expectedAth = _sha256B64Url(opts.accessToken);
460
+ if (typeof payload.ath !== "string" || payload.ath.length === 0) {
461
+ throw new AuthError("auth-dpop/missing-ath",
462
+ "accessToken supplied but proof has no ath claim");
463
+ }
464
+ if (!blamejsCrypto.timingSafeEqual(payload.ath, expectedAth)) {
465
+ throw new AuthError("auth-dpop/ath-mismatch",
466
+ "payload.ath does not match SHA-256 of access token");
467
+ }
468
+ }
469
+
470
+ // nonce — when caller supplies expected nonce, payload MUST match
471
+ if (typeof opts.nonce === "string" && opts.nonce.length > 0) {
472
+ if (typeof payload.nonce !== "string" || payload.nonce.length === 0) {
473
+ throw new AuthError("auth-dpop/missing-nonce",
474
+ "nonce expected but proof has no nonce claim");
475
+ }
476
+ if (payload.nonce !== opts.nonce) {
477
+ throw new AuthError("auth-dpop/nonce-mismatch",
478
+ "payload.nonce does not match expected");
479
+ }
480
+ }
481
+
482
+ // jti replay defense via b.nonceStore-shaped backend
483
+ if (opts.replayStore !== undefined && opts.replayStore !== null) {
484
+ validateOpts.optionalObjectWithMethod(
485
+ opts.replayStore, "checkAndInsert",
486
+ "verify: replayStore", AuthError, "auth-dpop/bad-replay-store",
487
+ "must expose checkAndInsert(jti, expireAtMs) — use b.nonceStore.create()");
488
+ var expireAtMs = nowMs + iatWindowSec * C.TIME.seconds(1) * 2;
489
+ var inserted;
490
+ try { inserted = await opts.replayStore.checkAndInsert(payload.jti, expireAtMs); }
491
+ catch (e) {
492
+ throw new AuthError("auth-dpop/replay-store-failed",
493
+ "replayStore.checkAndInsert threw: " + ((e && e.message) || String(e)));
494
+ }
495
+ if (inserted === false) {
496
+ throw new AuthError("auth-dpop/replay",
497
+ "DPoP proof jti='" + payload.jti + "' has been seen before — replay refused");
498
+ }
499
+ }
500
+
501
+ return { header: header, payload: payload, jkt: jkt };
502
+ }
503
+
504
+ module.exports = {
505
+ buildProof: buildProof,
506
+ verify: verify,
507
+ thumbprint: thumbprint,
508
+ SUPPORTED_ALGS: SUPPORTED_ALGS,
509
+ SUPPORTED_CLASSICAL_ALGS: SUPPORTED_CLASSICAL_ALGS,
510
+ SUPPORTED_PQC_ALGS: SUPPORTED_PQC_ALGS,
511
+ REFUSED_ALGS: REFUSED_ALGS,
512
+ };
package/lib/auth/jwt.js CHANGED
@@ -73,6 +73,7 @@
73
73
  var nodeCrypto = require("crypto");
74
74
  var C = require("../constants");
75
75
  var safeJson = require("../safe-json");
76
+ var validateOpts = require("../validate-opts");
76
77
  var { AuthError } = require("../framework-error");
77
78
 
78
79
  // Algorithm registry. The string keys are the JWT header `alg` values
@@ -219,6 +220,14 @@ async function verify(token, opts) {
219
220
  // exclusive with opts.publicKey: pass one or the other, not both.
220
221
  // The resolver receives the FULL decoded header and returns either
221
222
  // the key (sync) or a Promise<key> (async).
223
+ //
224
+ // SECURITY: when the resolver uses header.kid as a filename / map
225
+ // key / cache index, it MUST sanitize the kid first. Path-traversal
226
+ // (`../etc/passwd`), null-byte (`key\0..`), control chars, and
227
+ // similar shapes turn a kid lookup into an arbitrary-file-read
228
+ // primitive (CVE-2018-0114 java-jwt class). Use
229
+ // `b.guardJwt.kidSafe(header.kid)` — throws on traversal indicators
230
+ // and control bytes, returns the validated kid on success.
222
231
  var key;
223
232
  if (typeof opts.keyResolver === "function") {
224
233
  if (opts.publicKey !== undefined) {
@@ -325,6 +334,64 @@ async function verify(token, opts) {
325
334
  "sub='" + p.sub + "' does not match expected '" + opts.subject + "'");
326
335
  }
327
336
 
337
+ // Replay defense — when operator wires `replayStore`, the verifier
338
+ // refuses tokens whose jti has been seen within the replay window.
339
+ // Defends against captured-bearer-token replay (RFC 7519 §4.1.7
340
+ // recommends jti for exactly this purpose; CVE-class — token-reuse
341
+ // under TLS-terminated proxies, log scraping, browser-history
342
+ // exposure, leaked Authorization headers in shared dev tools).
343
+ //
344
+ // The store contract is the same atomic check-and-insert shape that
345
+ // `b.nonceStore` exposes:
346
+ //
347
+ // await replayStore.checkAndInsert(jti, expireAtMs)
348
+ // → true if this jti was NOT previously seen (now recorded)
349
+ // → false if this jti was already seen (replay)
350
+ //
351
+ // `expireAtMs` is the absolute unix-ms timestamp at which the entry
352
+ // should expire — matching `b.nonceStore`'s memory + cluster backends.
353
+ // `b.nonceStore.create({ backend: "memory" | "cluster" | custom })`
354
+ // returns a value satisfying this contract directly.
355
+ //
356
+ // Atomicity matters: a check-then-insert split would be a race window
357
+ // where two concurrent verifies on the same jti both succeed.
358
+ //
359
+ // The token MUST carry a jti claim — without one the verifier can't
360
+ // bind to anything, and the operator's intent to enforce replay
361
+ // defense was a config mistake. Throw at verify time so the
362
+ // misconfiguration surfaces, rather than silently letting every
363
+ // jti-less token through.
364
+ if (opts.replayStore !== undefined && opts.replayStore !== null) {
365
+ validateOpts.optionalObjectWithMethod(
366
+ opts.replayStore, "checkAndInsert",
367
+ "verify: replayStore", AuthError, "auth-jwt/bad-replay-store",
368
+ "must expose checkAndInsert(jti, expireAtMs) — use b.nonceStore.create() " +
369
+ "or supply a compatible backend");
370
+ if (typeof p.jti !== "string" || p.jti.length === 0) {
371
+ throw new AuthError("auth-jwt/replay-no-jti",
372
+ "verify: replayStore opt requires the token to carry a jti " +
373
+ "claim (RFC 7519 §4.1.7); got " +
374
+ (p.jti === undefined ? "<absent>" : typeof p.jti));
375
+ }
376
+ // expireAt = exp claim if present, else nowMs + 24h. The 24h cap
377
+ // bounds in-memory growth when an operator forgets to set exp.
378
+ var nowMs = (typeof opts.now === "number" ? opts.now : Date.now());
379
+ var expireAtMs = nowMs + C.TIME.hours(24);
380
+ if (typeof p.exp === "number") {
381
+ expireAtMs = p.exp * C.TIME.seconds(1);
382
+ }
383
+ var inserted;
384
+ try { inserted = await opts.replayStore.checkAndInsert(p.jti, expireAtMs); }
385
+ catch (e) {
386
+ throw new AuthError("auth-jwt/replay-store-failed",
387
+ "replayStore.checkAndInsert threw: " + ((e && e.message) || String(e)));
388
+ }
389
+ if (inserted === false) {
390
+ throw new AuthError("auth-jwt/replay",
391
+ "token jti='" + p.jti + "' has been seen before — replay refused");
392
+ }
393
+ }
394
+
328
395
  return p;
329
396
  }
330
397
 
package/lib/auth/oauth.js CHANGED
@@ -112,7 +112,6 @@ var httpClient = require("../http-client");
112
112
  var safeJson = require("../safe-json");
113
113
  var safeUrl = require("../safe-url");
114
114
  var { defineClass } = require("../framework-error");
115
- var { boot } = require("../log");
116
115
 
117
116
  // Cap on responses parsed from upstream OAuth providers. Token /
118
117
  // userinfo / discovery responses are tiny in spec; 256 KiB leaves
@@ -121,7 +120,6 @@ var { boot } = require("../log");
121
120
  var OAUTH_MAX_RESPONSE_BYTES = C.BYTES.kib(256);
122
121
 
123
122
  var OAuthError = defineClass("OAuthError", { alwaysPermanent: true });
124
- var log = boot("auth.oauth");
125
123
 
126
124
  // Vendor presets. Each entry has either { issuer } (OIDC) or explicit
127
125
  // endpoints { authorizationEndpoint, tokenEndpoint, userinfoEndpoint }.
@@ -292,7 +290,19 @@ function create(opts) {
292
290
  var clientId = opts.clientId;
293
291
  var clientSecret = opts.clientSecret || null; // public clients can omit
294
292
  var redirectUri = opts.redirectUri;
295
- var pkce = opts.pkce !== false;
293
+ // OAuth 2.1 baseline (draft-ietf-oauth-v2-1) makes PKCE mandatory for
294
+ // ALL clients (not just public clients). The framework refuses
295
+ // pkce: false outright — the prior "warn and continue" path was a
296
+ // pre-1.0 leniency that's now closed. Operators integrating with
297
+ // genuinely-broken legacy IdPs that don't accept code_challenge can
298
+ // strip the parameters at their own ingress; the framework primitive
299
+ // does not ship that escape hatch.
300
+ if (opts.pkce === false) {
301
+ throw new OAuthError("auth-oauth/pkce-required",
302
+ "create: pkce: false is refused. OAuth 2.1 (draft-ietf-oauth-v2-1) " +
303
+ "requires PKCE for all clients. Remove the opt or upgrade the IdP.");
304
+ }
305
+ var pkce = true;
296
306
  var clockSkewMs = typeof opts.clockSkewMs === "number" ? opts.clockSkewMs : DEFAULT_CLOCK_SKEW_MS;
297
307
  var discoveryCacheMs = typeof opts.discoveryCacheMs === "number"
298
308
  ? opts.discoveryCacheMs : DEFAULT_DISCOVERY_CACHE_MS;
@@ -310,9 +320,6 @@ function create(opts) {
310
320
  throw new OAuthError("auth-oauth/no-redirect-uri", "create: opts.redirectUri is required");
311
321
  }
312
322
  _validateUrl(redirectUri, allowHttp, "redirectUri");
313
- if (!pkce) {
314
- log("WARNING: PKCE disabled — modern IdPs require it; this should only be used for legacy providers");
315
- }
316
323
 
317
324
  // Resolve preset → effective config.
318
325
  var preset = null;
package/lib/cookies.js CHANGED
@@ -112,7 +112,8 @@ function _validateValue(value) {
112
112
  // header — never trust unscrubbed values reach the wire.
113
113
  function _scrubAttr(s) {
114
114
  if (typeof s !== "string") return s;
115
- return s.replace(/[\r\n\0]/g, "");
115
+ return s.replace(/[\r\n\0]/g, ""); // allow:duplicate-regex — CR/LF/NUL header-injection rejection appears in cookies / mail / security-headers; each is the boundary primitive for its domain
116
+
116
117
  }
117
118
 
118
119
  function parse(cookieHeader) {