@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.
- package/CHANGELOG.md +20 -0
- package/index.js +2 -0
- package/lib/auth/aal.js +149 -0
- package/lib/auth/dpop.js +512 -0
- package/lib/auth/jwt.js +67 -0
- package/lib/auth/oauth.js +13 -6
- package/lib/cookies.js +2 -1
- package/lib/mail-unsubscribe.js +160 -0
- package/lib/mail.js +131 -6
- package/lib/middleware/dpop.js +173 -0
- package/lib/middleware/gpc.js +120 -0
- package/lib/middleware/index.js +8 -0
- package/lib/middleware/require-aal.js +107 -0
- package/lib/middleware/security-headers.js +29 -1
- package/lib/network-dns.js +131 -12
- package/lib/network-smtp-policy.js +7 -3
- package/lib/router.js +42 -4
- package/lib/vendor/MANIFEST.json +21 -5
- package/lib/websocket.js +21 -5
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/dpop.js
ADDED
|
@@ -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
|
-
|
|
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) {
|