@blamejs/core 0.14.4 → 0.14.6

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/lib/a2a-tasks.js +6 -6
  4. package/lib/ai-input.js +1 -1
  5. package/lib/auth/sd-jwt-vc.js +1 -1
  6. package/lib/calendar.js +6 -6
  7. package/lib/content-credentials.js +2 -2
  8. package/lib/cra-report.js +3 -3
  9. package/lib/guard-cidr.js +1 -1
  10. package/lib/http-client-cache.js +1 -1
  11. package/lib/mail-auth.js +1 -1
  12. package/lib/mail-crypto-smime.js +1 -1
  13. package/lib/mail-deploy.js +1 -1
  14. package/lib/mail-dkim.js +1 -1
  15. package/lib/mail-server-jmap.js +2 -2
  16. package/lib/mcp.js +6 -6
  17. package/lib/middleware/age-gate.js +20 -7
  18. package/lib/middleware/bearer-auth.js +36 -35
  19. package/lib/middleware/bot-guard.js +17 -5
  20. package/lib/middleware/compose-pipeline.js +1 -1
  21. package/lib/middleware/cors.js +28 -12
  22. package/lib/middleware/csrf-protect.js +22 -14
  23. package/lib/middleware/daily-byte-quota.js +27 -13
  24. package/lib/middleware/deny-response.js +140 -0
  25. package/lib/middleware/dpop.js +32 -19
  26. package/lib/middleware/fetch-metadata.js +21 -12
  27. package/lib/middleware/host-allowlist.js +19 -8
  28. package/lib/middleware/index.js +3 -0
  29. package/lib/middleware/network-allowlist.js +24 -10
  30. package/lib/middleware/rate-limit.js +22 -5
  31. package/lib/middleware/require-aal.js +25 -10
  32. package/lib/middleware/require-auth.js +32 -16
  33. package/lib/middleware/require-bound-key.js +49 -18
  34. package/lib/middleware/require-content-type.js +19 -8
  35. package/lib/middleware/require-methods.js +17 -7
  36. package/lib/middleware/require-mtls.js +27 -14
  37. package/lib/network.js +4 -4
  38. package/lib/safe-decompress.js +1 -1
  39. package/lib/safe-url.js +1 -1
  40. package/lib/stream-throttle.js +2 -2
  41. package/lib/websocket.js +2 -2
  42. package/package.json +1 -1
  43. package/sbom.cdx.json +6 -6
@@ -55,6 +55,7 @@ var audit = lazyRequire(function () { return require("../audit"); });
55
55
  var requestHelpers = require("../request-helpers");
56
56
  var safeUrl = require("../safe-url");
57
57
  var validateOpts = require("../validate-opts");
58
+ var denyResponse = require("./deny-response").denyResponse;
58
59
  var { defineClass } = require("../framework-error");
59
60
 
60
61
  // CORS audit events use the proxy-aware client IP only when the
@@ -185,6 +186,8 @@ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, str
185
186
  * refuseUnknown: boolean, // default true
186
187
  * strictNullOrigin: boolean, // default true
187
188
  * trustProxy: boolean|number,
189
+ * onDeny: function(req, res, info): void, // own every refusal; info = { status, reason, origin, header? }
190
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
188
191
  * }
189
192
  *
190
193
  * @example
@@ -202,7 +205,7 @@ function create(opts) {
202
205
  validateOpts(opts, [
203
206
  "origins", "siteOrigin", "methods", "headers", "exposeHeaders",
204
207
  "credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
205
- "strictNullOrigin", "allowPrivateNetwork",
208
+ "strictNullOrigin", "allowPrivateNetwork", "onDeny", "problemDetails",
206
209
  ], "middleware.cors");
207
210
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
208
211
  ? opts.trustProxy : false;
@@ -269,6 +272,22 @@ function create(opts) {
269
272
  // header). Operators with a no-referrer page producing legitimate
270
273
  // Origin: null on same-origin POSTs flip to false explicitly.
271
274
  var strictNullOrigin = opts.strictNullOrigin !== false;
275
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
276
+ var problemMode = opts.problemDetails === true;
277
+
278
+ function _refuse(req, res, reason, body, ext) {
279
+ denyResponse(req, res, {
280
+ onDeny: onDeny,
281
+ problem: problemMode,
282
+ status: requestHelpers.HTTP_STATUS.FORBIDDEN,
283
+ info: Object.assign({ status: 403, reason: reason }, ext || {}),
284
+ problemCode: "cors-refused",
285
+ problemTitle: "Forbidden",
286
+ problemDetail: body,
287
+ contentType: "text/plain",
288
+ body: body,
289
+ });
290
+ }
272
291
 
273
292
  return function cors(req, res, next) {
274
293
  var origin = req.headers && req.headers.origin;
@@ -302,9 +321,8 @@ function create(opts) {
302
321
  requestId: req.requestId,
303
322
  });
304
323
  } catch (_e) { /* audit best-effort */ }
305
- if (typeof res.writeHead === "function") {
306
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
307
- res.end("CORS: origin not allowed");
324
+ if (typeof res.writeHead === "function" || onDeny) {
325
+ _refuse(req, res, "origin-not-allowed", "CORS: origin not allowed", { origin: origin });
308
326
  return;
309
327
  }
310
328
  }
@@ -333,10 +351,9 @@ function create(opts) {
333
351
  var asked = requestHelpers.parseListHeader(requestedHdrs, { lowercase: true });
334
352
  for (var ah = 0; ah < asked.length; ah++) {
335
353
  if (allowedHeadersSet.indexOf(asked[ah]) === -1) {
336
- if (typeof res.writeHead === "function") {
337
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
338
- res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
339
- }
354
+ _refuse(req, res, "requested-header-not-allowed",
355
+ "CORS: requested header '" + asked[ah] + "' not in allow-list",
356
+ { origin: origin, header: asked[ah] });
340
357
  return;
341
358
  }
342
359
  }
@@ -357,10 +374,9 @@ function create(opts) {
357
374
  res.setHeader("Access-Control-Allow-Private-Network", "true");
358
375
  }
359
376
  } else {
360
- if (typeof res.writeHead === "function") {
361
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
362
- res.end("CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)");
363
- }
377
+ _refuse(req, res, "private-network-not-permitted",
378
+ "CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)",
379
+ { origin: origin });
364
380
  return;
365
381
  }
366
382
  }
@@ -70,6 +70,7 @@ var lazyRequire = require("../lazy-require");
70
70
  var forms = require("../forms");
71
71
  var requestHelpers = require("../request-helpers");
72
72
  var validateOpts = require("../validate-opts");
73
+ var denyResponse = require("./deny-response").denyResponse;
73
74
  var audit = lazyRequire(function () { return require("../audit"); });
74
75
 
75
76
  var DEFAULT_FIELD_NAME = "_csrf";
@@ -218,15 +219,18 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
218
219
  return null;
219
220
  }
220
221
 
221
- function _writeReject(res, message) {
222
- if (typeof res.writeHead === "function") {
223
- var body = JSON.stringify({ error: message });
224
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
225
- "Content-Type": "application/json; charset=utf-8",
226
- "Content-Length": Buffer.byteLength(body),
227
- });
228
- res.end(body);
229
- }
222
+ function _writeReject(req, res, message, reason, onDeny, problemMode) {
223
+ denyResponse(req, res, {
224
+ onDeny: onDeny,
225
+ problem: problemMode,
226
+ status: requestHelpers.HTTP_STATUS.FORBIDDEN,
227
+ info: { status: 403, reason: reason },
228
+ problemCode: "csrf-refused",
229
+ problemTitle: "Forbidden",
230
+ problemDetail: message,
231
+ contentType: "application/json; charset=utf-8",
232
+ body: JSON.stringify({ error: message }),
233
+ });
230
234
  }
231
235
 
232
236
  /**
@@ -262,6 +266,8 @@ function _writeReject(res, message) {
262
266
  * trustProxy: boolean|number,
263
267
  * audit: boolean,
264
268
  * skipStateless: boolean, // default false — skip validation for Authorization-header / cookieless (not-CSRF-able) requests
269
+ * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
270
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
265
271
  * }
266
272
  *
267
273
  * @example
@@ -279,8 +285,10 @@ function create(opts) {
279
285
  validateOpts(opts, [
280
286
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
281
287
  "trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
282
- "requireOrigin", "skipStateless",
288
+ "requireOrigin", "skipStateless", "onDeny", "problemDetails",
283
289
  ], "middleware.csrfProtect");
290
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
291
+ var problemMode = opts.problemDetails === true;
284
292
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
285
293
  ? opts.trustProxy : false;
286
294
  var _isHttps = _isHttpsFor(trustProxy);
@@ -477,7 +485,7 @@ function create(opts) {
477
485
  var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
478
486
  if (bare !== "application/json") {
479
487
  _emitDenied(req, "non-JSON content-type: " + (bare || "<absent>"));
480
- return _writeReject(res, "CSRF: state-changing requests require Content-Type: application/json.");
488
+ return _writeReject(req, res, "CSRF: state-changing requests require Content-Type: application/json.", "content-type-required", onDeny, problemMode);
481
489
  }
482
490
  }
483
491
 
@@ -489,7 +497,7 @@ function create(opts) {
489
497
  var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps, requireOriginOpt);
490
498
  if (originReason !== null) {
491
499
  _emitDenied(req, "origin/referer: " + originReason);
492
- return _writeReject(res, "CSRF cross-origin request refused.");
500
+ return _writeReject(req, res, "CSRF cross-origin request refused.", "cross-origin-refused", onDeny, problemMode);
493
501
  }
494
502
  }
495
503
 
@@ -499,7 +507,7 @@ function create(opts) {
499
507
  }
500
508
  if (!expected) {
501
509
  _emitDenied(req, cookieCfg ? "no token cookie issued yet" : "no expected token in session");
502
- return _writeReject(res, "CSRF token mismatch.");
510
+ return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
503
511
  }
504
512
 
505
513
  // Header path first — covers JSON / AJAX / multipart cases.
@@ -517,7 +525,7 @@ function create(opts) {
517
525
 
518
526
  if (!forms.verifyCsrfToken(submitted || "", expected)) {
519
527
  _emitDenied(req, "submitted token does not match expected");
520
- return _writeReject(res, "CSRF token mismatch.");
528
+ return _writeReject(req, res, "CSRF token mismatch.", "token-mismatch", onDeny, problemMode);
521
529
  }
522
530
 
523
531
  return next();
@@ -41,6 +41,7 @@ var defineClass = require("../framework-error").defineClass;
41
41
  var lazyRequire = require("../lazy-require");
42
42
  var networkByteQuota = require("../network-byte-quota");
43
43
  var validateOpts = require("../validate-opts");
44
+ var denyResponse = require("./deny-response").denyResponse;
44
45
 
45
46
  var audit = lazyRequire(function () { return require("../audit"); });
46
47
  var observability = lazyRequire(function () { return require("../observability"); });
@@ -76,7 +77,9 @@ function _defaultGetKey(req) {
76
77
  * bytesPerDay: number, // required, positive, finite
77
78
  * getKey: function(req): string|null, // default: req client IP
78
79
  * cache: object, // null = in-memory single-node
79
- * onExceeded: function(req, res, info): void,
80
+ * onDeny: function(req, res, info): void, // own the 429; info = { status, reason, quota, total, retryAfterSec }
81
+ * onExceeded: function(req, res, info): void, // legacy alias for onDeny
82
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
80
83
  * skipPaths: string[],
81
84
  * now: function(): number,
82
85
  * audit: boolean, // default true
@@ -94,7 +97,7 @@ function create(opts) {
94
97
  opts = opts || {};
95
98
  validateOpts(opts, [
96
99
  "bytesPerDay", "cache", "getKey", "audit",
97
- "onExceeded", "skipPaths", "now",
100
+ "onDeny", "onExceeded", "problemDetails", "skipPaths", "now",
98
101
  ], "middleware.dailyByteQuota");
99
102
 
100
103
  if (typeof opts.bytesPerDay !== "number" || !isFinite(opts.bytesPerDay) || opts.bytesPerDay <= 0) {
@@ -105,7 +108,11 @@ function create(opts) {
105
108
  var bytesPerDay = opts.bytesPerDay;
106
109
  var getKey = typeof opts.getKey === "function" ? opts.getKey : _defaultGetKey;
107
110
  var auditOn = opts.audit !== false;
108
- var onExceeded = typeof opts.onExceeded === "function" ? opts.onExceeded : null;
111
+ // onDeny is the canonical hook across the deny-path middleware
112
+ // family; onExceeded is the original name kept working as an alias.
113
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny
114
+ : (typeof opts.onExceeded === "function" ? opts.onExceeded : null);
115
+ var problemMode = opts.problemDetails === true;
109
116
  var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
110
117
  var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
111
118
 
@@ -169,22 +176,29 @@ function create(opts) {
169
176
  _emitMetric("refused", 1, { reason: "quota-exceeded" });
170
177
  _emitAudit("refused", "denied", { key: key, total: total, quota: bytesPerDay });
171
178
  var info = {
179
+ status: 429,
180
+ reason: "quota-exceeded",
172
181
  quota: bytesPerDay,
173
182
  total: total,
174
183
  retryAfterSec: Math.ceil(C.TIME.hours(1) / C.TIME.seconds(1)),
175
184
  };
176
- if (onExceeded) {
177
- try { return onExceeded(req, res, info); }
178
- catch (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); }
179
- }
180
- if (!res.writableEnded) {
181
- res.writeHead(429, {
182
- "Content-Type": "application/json; charset=utf-8",
185
+ denyResponse(req, res, {
186
+ onDeny: onDeny,
187
+ problem: problemMode,
188
+ status: 429,
189
+ info: info,
190
+ problemCode: "daily-byte-quota-exceeded",
191
+ problemTitle: "Too Many Requests",
192
+ problemDetail: "Daily byte quota exceeded; retry after the indicated interval.",
193
+ problemExt: { quota: bytesPerDay, total: total, retryAfter: info.retryAfterSec },
194
+ headers: {
183
195
  "Retry-After": String(info.retryAfterSec),
184
196
  "Cache-Control": "no-store",
185
- });
186
- res.end(JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }));
187
- }
197
+ },
198
+ contentType: "application/json; charset=utf-8",
199
+ body: JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }),
200
+ onThrow: function (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); },
201
+ });
188
202
  return;
189
203
  }
190
204
 
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * Shared deny-path response writer for the request-lifecycle
4
+ * middlewares that refuse a request (401 / 403 / 405 / 415 / 429 /
5
+ * 451 / misdirected-request). Every deny-path middleware routes its
6
+ * refusal through `denyResponse` so a consumer gets one uniform way
7
+ * to shape it, instead of each middleware hardcoding its own body +
8
+ * Content-Type.
9
+ *
10
+ * Three resolution modes, checked in order:
11
+ *
12
+ * 1. `onDeny(req, res, info)` operator hook — when supplied, the
13
+ * consumer owns the response. `info` carries the machine
14
+ * `status` / `reason` plus the middleware-specific fields. The
15
+ * hook is wrapped so a throw is audited (via `ctx.onThrow`) and
16
+ * then falls through to the default write rather than crashing
17
+ * the request that triggered the refusal. A hook that returns
18
+ * without writing also falls through — the response can never
19
+ * hang on a no-op hook.
20
+ *
21
+ * 2. `problem: true` — emit RFC 9457 `application/problem+json` by
22
+ * composing `b.problemDetails`. The middleware supplies the
23
+ * `type` / `title` / `detail` and any extension members; the
24
+ * deny-path response headers (`Allow` / `WWW-Authenticate` /
25
+ * `Retry-After` / `Accept`) are merged onto the problem
26
+ * response so content negotiation does not drop them.
27
+ *
28
+ * 3. Default — the middleware's existing body + Content-Type. No
29
+ * behavior change when neither knob is set.
30
+ *
31
+ * This is an internal helper (no public `b.*` surface); the consumer
32
+ * contract is the `onDeny` / `problemDetails` opts documented on each
33
+ * middleware that composes it.
34
+ */
35
+
36
+ var problemDetails = require("../problem-details");
37
+
38
+ function _isFn(x) { return typeof x === "function"; }
39
+
40
+ function _mergeInto(target, extra) {
41
+ if (!extra || typeof extra !== "object") return target;
42
+ var keys = Object.keys(extra);
43
+ for (var i = 0; i < keys.length; i += 1) {
44
+ target[keys[i]] = extra[keys[i]];
45
+ }
46
+ return target;
47
+ }
48
+
49
+ /**
50
+ * Resolve a deny-path refusal through the uniform hook / problem+json
51
+ * / default chain. Returns whatever the `onDeny` hook returns when it
52
+ * owns the response, otherwise `undefined`.
53
+ *
54
+ * ctx fields:
55
+ * onDeny: function (req, res, info) | null — operator hook
56
+ * problem: boolean — emit application/problem+json
57
+ * status: number — HTTP status (100..599)
58
+ * info: object — passed verbatim to onDeny; also seeds
59
+ * the problem document (status / reason)
60
+ * problemType: string? — RFC 9457 `type` (URI reference); when
61
+ * absent, built from `problemCode`
62
+ * problemCode: string? — type-URI suffix; resolves to
63
+ * `<problemDetails base>/<code>` (the same
64
+ * `<base>/<code>` convention as fromError)
65
+ * problemTitle: string? — RFC 9457 `title`
66
+ * problemDetail: string? — RFC 9457 `detail`
67
+ * problemExt: object? — extra problem members (reserved names
68
+ * dropped); siblings per RFC 9457 §3.2
69
+ * headers: object? — extra response headers (Allow /
70
+ * WWW-Authenticate / Retry-After / Accept
71
+ * / Cache-Control)
72
+ * contentType: string — default-mode Content-Type
73
+ * body: string|Buffer — default-mode body
74
+ * onThrow: function (err) ? — audit sink when onDeny throws
75
+ */
76
+ function denyResponse(req, res, ctx) {
77
+ var info = (ctx.info && typeof ctx.info === "object") ? ctx.info : {};
78
+
79
+ if (_isFn(ctx.onDeny)) {
80
+ try {
81
+ var returned = ctx.onDeny(req, res, info);
82
+ if (res.writableEnded) return returned;
83
+ // Hook ran but did not write — fall through to the default so
84
+ // the response can never hang on a no-op hook.
85
+ } catch (e) {
86
+ if (_isFn(ctx.onThrow)) {
87
+ try { ctx.onThrow(e); } catch (_e) { /* drop-silent */ }
88
+ }
89
+ if (res.writableEnded) return undefined;
90
+ // Hook threw before writing — fall through to the default.
91
+ }
92
+ }
93
+
94
+ if (res.writableEnded || !_isFn(res.writeHead)) return undefined;
95
+
96
+ var extra = (ctx.headers && typeof ctx.headers === "object") ? ctx.headers : null;
97
+
98
+ if (ctx.problem) {
99
+ var fields = { status: ctx.status };
100
+ if (ctx.problemType) fields.type = ctx.problemType;
101
+ if (ctx.problemTitle) fields.title = ctx.problemTitle;
102
+ if (ctx.problemDetail) fields.detail = ctx.problemDetail;
103
+ if (ctx.problemExt && typeof ctx.problemExt === "object") {
104
+ var ek = Object.keys(ctx.problemExt);
105
+ for (var i = 0; i < ek.length; i += 1) {
106
+ if (problemDetails.RESERVED_FIELDS.indexOf(ek[i]) === -1) {
107
+ fields[ek[i]] = ctx.problemExt[ek[i]];
108
+ }
109
+ }
110
+ }
111
+ var problem;
112
+ try {
113
+ problem = problemDetails.create(fields);
114
+ } catch (_e) {
115
+ // A bad extension shape (prototype-pollution key, out-of-range
116
+ // status) must not turn a refusal into a 500 — degrade to the
117
+ // bare status document.
118
+ problem = problemDetails.create({ status: ctx.status });
119
+ }
120
+ // Set the deny-path headers before respond() so content
121
+ // negotiation does not lose Allow / WWW-Authenticate / Retry-After.
122
+ if (extra) {
123
+ var hk = Object.keys(extra);
124
+ for (var h = 0; h < hk.length; h += 1) {
125
+ res.setHeader(hk[h], extra[hk[h]]);
126
+ }
127
+ }
128
+ problemDetails.respond(res, problem);
129
+ return undefined;
130
+ }
131
+
132
+ var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
133
+ res.writeHead(ctx.status, head);
134
+ res.end((ctx.body === undefined || ctx.body === null) ? "" : ctx.body);
135
+ return undefined;
136
+ }
137
+
138
+ module.exports = {
139
+ denyResponse: denyResponse,
140
+ };
@@ -40,6 +40,7 @@ var bCrypto = require("../crypto");
40
40
  var lazyRequire = require("../lazy-require");
41
41
  var requestHelpers = require("../request-helpers");
42
42
  var validateOpts = require("../validate-opts");
43
+ var denyResponse = require("./deny-response").denyResponse;
43
44
  var { AuthError } = require("../framework-error");
44
45
 
45
46
  var dpop = lazyRequire(function () { return require("../auth/dpop"); });
@@ -49,20 +50,28 @@ var audit = lazyRequire(function () { return require("../audit"); });
49
50
  // of entropy after base64url, far above the spec's "unpredictable" bar).
50
51
  var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
51
52
 
52
- function _writeUnauthorized(res, errorCode, description, freshNonce) {
53
- if (res.headersSent) return;
53
+ function _writeUnauthorized(req, res, errorCode, description, freshNonce, onDeny, problemMode) {
54
54
  var body = JSON.stringify({ error: errorCode, error_description: description });
55
55
  // RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
56
56
  var challenge = 'DPoP error="' + errorCode + '", error_description="' +
57
57
  description.replace(/"/g, "'") + '"';
58
- var headers = { // HTTP 401 status
59
- "Content-Type": "application/json; charset=utf-8",
60
- "Content-Length": Buffer.byteLength(body),
58
+ var headers = {
61
59
  "WWW-Authenticate": challenge,
62
60
  };
63
61
  if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
64
- res.writeHead(401, headers);
65
- res.end(body);
62
+ denyResponse(req, res, {
63
+ onDeny: onDeny,
64
+ problem: problemMode,
65
+ status: 401, // HTTP 401 status
66
+ info: { status: 401, reason: errorCode, error_description: description },
67
+ problemCode: "dpop-" + errorCode.replace(/_/g, "-"),
68
+ problemTitle: "Unauthorized",
69
+ problemDetail: description,
70
+ problemExt: { error: errorCode, error_description: description },
71
+ headers: headers,
72
+ contentType: "application/json; charset=utf-8",
73
+ body: body,
74
+ });
66
75
  }
67
76
 
68
77
  // RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
@@ -201,6 +210,8 @@ function _reconstructHtu(req, mopts) {
201
210
  * nonceRotateSec: number,
202
211
  * requireNonce: boolean,
203
212
  * audit: boolean, // default true
213
+ * onDeny: function(req, res, info): void, // own the 401; info = { status, reason, error_description }
214
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
204
215
  * }
205
216
  *
206
217
  * @example
@@ -220,9 +231,11 @@ function create(opts) {
220
231
  // v0.9.4 — opt-in trust gate for X-Forwarded-Proto/Host when
221
232
  // reconstructing htu. Default off (audit 2026-05-11); operators
222
233
  // with a confirmed-trusted front proxy set this to `true`.
223
- "trustForwardedHeaders",
234
+ "trustForwardedHeaders", "onDeny", "problemDetails",
224
235
  ], "middleware.dpop");
225
236
 
237
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
238
+ var problemMode = opts.problemDetails === true;
226
239
  var auditOn = opts.audit !== false;
227
240
  var algorithms = opts.algorithms;
228
241
  var iatWindowSec = opts.iatWindowSec;
@@ -265,14 +278,14 @@ function create(opts) {
265
278
  var middleware = async function dpopMiddleware(req, res, next) {
266
279
  var proofHeader = req.headers && req.headers.dpop;
267
280
  if (typeof proofHeader !== "string" || proofHeader.length === 0) {
268
- return _writeUnauthorized(res,
281
+ return _writeUnauthorized(req, res,
269
282
  nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
270
- "DPoP header required", _freshNonce());
283
+ "DPoP header required", _freshNonce(), onDeny, problemMode);
271
284
  }
272
285
  // RFC 9449 §4.1 — only ONE DPoP header value per request.
273
286
  if (Array.isArray(proofHeader)) {
274
- return _writeUnauthorized(res, "invalid_dpop_proof",
275
- "multiple DPoP headers are not allowed");
287
+ return _writeUnauthorized(req, res, "invalid_dpop_proof",
288
+ "multiple DPoP headers are not allowed", null, onDeny, problemMode);
276
289
  }
277
290
  // AUTH-15 — RFC 9449 §4.1 single-value invariant. node:http
278
291
  // collapses repeated headers into a comma-joined string when the
@@ -283,13 +296,13 @@ function create(opts) {
283
296
  // verify() call below would only see the first one, leaving the
284
297
  // second unprocessed).
285
298
  if (proofHeader.indexOf(",") !== -1) {
286
- return _writeUnauthorized(res, "invalid_dpop_proof",
287
- "multiple DPoP proofs in one header value are not allowed");
299
+ return _writeUnauthorized(req, res, "invalid_dpop_proof",
300
+ "multiple DPoP proofs in one header value are not allowed", null, onDeny, problemMode);
288
301
  }
289
302
 
290
303
  var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
291
304
  if (!htu) {
292
- return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
305
+ return _writeUnauthorized(req, res, "invalid_dpop_proof", "could not reconstruct htu", null, onDeny, problemMode);
293
306
  }
294
307
  var htm = (req.method || "").toUpperCase();
295
308
 
@@ -340,9 +353,9 @@ function create(opts) {
340
353
  if (e && (e.code === "auth-dpop/missing-nonce" || e.code === "auth-dpop/nonce-mismatch")) {
341
354
  errorCode = "use_dpop_nonce";
342
355
  }
343
- return _writeUnauthorized(res, errorCode,
356
+ return _writeUnauthorized(req, res, errorCode,
344
357
  (e && e.message) || "DPoP proof verification failed",
345
- _freshNonce());
358
+ _freshNonce(), onDeny, problemMode);
346
359
  }
347
360
 
348
361
  // Server-managed nonce check — payload MUST carry a recognized
@@ -360,8 +373,8 @@ function create(opts) {
360
373
  });
361
374
  } catch (_ignored) { /* drop-silent */ }
362
375
  }
363
- return _writeUnauthorized(res, "use_dpop_nonce",
364
- "DPoP-Nonce required (server-managed challenge)", _freshNonce());
376
+ return _writeUnauthorized(req, res, "use_dpop_nonce",
377
+ "DPoP-Nonce required (server-managed challenge)", _freshNonce(), onDeny, problemMode);
365
378
  }
366
379
  }
367
380
 
@@ -36,20 +36,25 @@
36
36
  var requestHelpers = require("../request-helpers");
37
37
  var validateOpts = require("../validate-opts");
38
38
  var lazyRequire = require("../lazy-require");
39
+ var denyResponse = require("./deny-response").denyResponse;
39
40
 
40
41
  var audit = lazyRequire(function () { return require("../audit"); });
41
42
  var observability = lazyRequire(function () { return require("../observability"); });
42
43
 
43
44
  var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
44
45
 
45
- function _writeReject(res, message) {
46
- if (res.headersSent) return;
47
- var body = JSON.stringify({ error: message });
48
- res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
49
- "Content-Type": "application/json; charset=utf-8",
50
- "Content-Length": Buffer.byteLength(body),
46
+ function _writeReject(req, res, message, reason, onDeny, problemMode) {
47
+ denyResponse(req, res, {
48
+ onDeny: onDeny,
49
+ problem: problemMode,
50
+ status: requestHelpers.HTTP_STATUS.FORBIDDEN,
51
+ info: { status: 403, reason: reason },
52
+ problemCode: "fetch-metadata-refused",
53
+ problemTitle: "Forbidden",
54
+ problemDetail: message,
55
+ contentType: "application/json; charset=utf-8",
56
+ body: JSON.stringify({ error: message }),
51
57
  });
52
- res.end(body);
53
58
  }
54
59
 
55
60
  /**
@@ -79,6 +84,8 @@ function _writeReject(res, message) {
79
84
  * allowedNavigate: boolean, // default true
80
85
  * methods: string[], // default POST/PUT/DELETE/PATCH
81
86
  * audit: boolean, // default true
87
+ * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
88
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
82
89
  * }
83
90
  *
84
91
  * @example
@@ -95,9 +102,11 @@ function create(opts) {
95
102
  opts = opts || {};
96
103
  validateOpts(opts, [
97
104
  "allowSameSite", "allowCrossSite", "allowMissing",
98
- "allowedDest", "allowedNavigate", "methods", "audit",
105
+ "allowedDest", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
99
106
  ], "middleware.fetchMetadata");
100
107
 
108
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
109
+ var problemMode = opts.problemDetails === true;
101
110
  var allowSameSite = opts.allowSameSite !== false;
102
111
  var allowCrossSite = opts.allowCrossSite === true;
103
112
  var allowMissing = opts.allowMissing !== false;
@@ -135,7 +144,7 @@ function create(opts) {
135
144
  // Defer to other auth/CSRF layers per allowMissing.
136
145
  if (!allowMissing) {
137
146
  _emitDenied(req, "fetch-metadata-missing");
138
- return _writeReject(res, "Fetch-metadata required.");
147
+ return _writeReject(req, res, "Fetch-metadata required.", "fetch-metadata-missing", onDeny, problemMode);
139
148
  }
140
149
  return next();
141
150
  }
@@ -144,7 +153,7 @@ function create(opts) {
144
153
  if (site === "none") {
145
154
  if (allowedNavigate) return next();
146
155
  _emitDenied(req, "navigate-disallowed");
147
- return _writeReject(res, "Direct navigation not allowed for this method.");
156
+ return _writeReject(req, res, "Direct navigation not allowed for this method.", "navigation-not-allowed", onDeny, problemMode);
148
157
  }
149
158
 
150
159
  if (site === "same-origin") return next();
@@ -152,7 +161,7 @@ function create(opts) {
152
161
  if (site === "same-site") {
153
162
  if (allowSameSite) return next();
154
163
  _emitDenied(req, "same-site-disallowed");
155
- return _writeReject(res, "Same-site request not allowed.");
164
+ return _writeReject(req, res, "Same-site request not allowed.", "same-site-not-allowed", onDeny, problemMode);
156
165
  }
157
166
 
158
167
  // cross-site
@@ -164,7 +173,7 @@ function create(opts) {
164
173
  ", dest=" + (dest || "?") + ")");
165
174
  try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
166
175
  catch (_e) { /* best-effort */ }
167
- return _writeReject(res, "Cross-site request refused.");
176
+ return _writeReject(req, res, "Cross-site request refused.", "cross-site-refused", onDeny, problemMode);
168
177
  };
169
178
  }
170
179