@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
@@ -41,6 +41,7 @@
41
41
  var lazyRequire = require("../lazy-require");
42
42
  var requestHelpers = require("../request-helpers");
43
43
  var validateOpts = require("../validate-opts");
44
+ var denyResponse = require("./deny-response").denyResponse;
44
45
  var { defineClass } = require("../framework-error");
45
46
 
46
47
  var HostAllowlistError = defineClass("HostAllowlistError", { alwaysPermanent: true });
@@ -102,6 +103,8 @@ function _matches(entry, actual) {
102
103
  * denyStatus: number, // default 421
103
104
  * denyBody: string,
104
105
  * audit: boolean, // default true
106
+ * onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, host }
107
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
105
108
  * }
106
109
  *
107
110
  * @example
@@ -114,7 +117,7 @@ function _matches(entry, actual) {
114
117
  function create(opts) {
115
118
  validateOpts.requireObject(opts, "middleware.hostAllowlist", HostAllowlistError);
116
119
  validateOpts(opts, [
117
- "hosts", "denyStatus", "denyBody", "audit",
120
+ "hosts", "denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
118
121
  ], "middleware.hostAllowlist");
119
122
 
120
123
  if (!Array.isArray(opts.hosts) || opts.hosts.length === 0) {
@@ -134,6 +137,8 @@ function create(opts) {
134
137
  var denyStatus = (typeof opts.denyStatus === "number") ? opts.denyStatus : 421; // HTTP 421 status
135
138
  var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Misdirected Request";
136
139
  var auditOn = opts.audit !== false;
140
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
141
+ var problemMode = opts.problemDetails === true;
137
142
 
138
143
  return function hostAllowlistMiddleware(req, res, next) {
139
144
  var raw = req.headers && req.headers.host;
@@ -141,7 +146,7 @@ function create(opts) {
141
146
  // RFC 7230 §5.4 — a request without a Host header is malformed
142
147
  // for HTTP/1.1; HTTP/2 maps :authority into req.headers.host
143
148
  // automatically. Reject either shape.
144
- _deny(res, denyStatus, denyBody);
149
+ _deny(req, res, "missing-host", null);
145
150
  _emitDenied(req, "missing-host");
146
151
  return;
147
152
  }
@@ -151,20 +156,26 @@ function create(opts) {
151
156
  if (_matches(hosts[hi], actual)) { matched = true; break; }
152
157
  }
153
158
  if (!matched) {
154
- _deny(res, denyStatus, denyBody);
159
+ _deny(req, res, "host-not-in-allowlist", actual);
155
160
  _emitDenied(req, "host-not-in-allowlist", actual);
156
161
  return;
157
162
  }
158
163
  return next();
159
164
  };
160
165
 
161
- function _deny(res, status, body) {
166
+ function _deny(req, res, reason, host) {
162
167
  if (res.headersSent) return;
163
- res.writeHead(status, {
164
- "Content-Type": "text/plain; charset=utf-8",
165
- "Content-Length": Buffer.byteLength(body),
168
+ denyResponse(req, res, {
169
+ onDeny: onDeny,
170
+ problem: problemMode,
171
+ status: denyStatus,
172
+ info: { status: denyStatus, reason: reason, host: host },
173
+ problemCode: "misdirected-request",
174
+ problemTitle: "Misdirected Request",
175
+ problemDetail: "The request Host header is not served by this endpoint.",
176
+ contentType: "text/plain; charset=utf-8",
177
+ body: denyBody,
166
178
  });
167
- res.end(body);
168
179
  }
169
180
 
170
181
  function _emitDenied(req, reason, actual) {
@@ -53,6 +53,7 @@ var requireAal = require("./require-aal");
53
53
  var requireAuth = require("./require-auth");
54
54
  var requireContentType = require("./require-content-type");
55
55
  var ageGate = require("./age-gate");
56
+ var requireBoundKey = require("./require-bound-key");
56
57
  var requireMethods = require("./require-methods");
57
58
  var requireMtls = require("./require-mtls");
58
59
  var requireStepUp = require("./require-step-up");
@@ -85,6 +86,7 @@ module.exports = {
85
86
  requireAuth: requireAuth.create,
86
87
  requireContentType: requireContentType.create,
87
88
  ageGate: ageGate.create,
89
+ requireBoundKey: requireBoundKey.create,
88
90
  requireMethods: requireMethods.create,
89
91
  requireMtls: requireMtls.create,
90
92
  requireStepUp: requireStepUp.create,
@@ -145,6 +147,7 @@ module.exports = {
145
147
  requireAuth: requireAuth,
146
148
  requireContentType: requireContentType,
147
149
  ageGate: ageGate,
150
+ requireBoundKey: requireBoundKey,
148
151
  requireMethods: requireMethods,
149
152
  requireMtls: requireMtls,
150
153
  requireStepUp: requireStepUp,
@@ -48,6 +48,7 @@ var lazyRequire = require("../lazy-require");
48
48
  var requestHelpers = require("../request-helpers");
49
49
  var ssrfGuard = require("../ssrf-guard");
50
50
  var validateOpts = require("../validate-opts");
51
+ var denyResponse = require("./deny-response").denyResponse;
51
52
  var { defineClass } = require("../framework-error");
52
53
 
53
54
  var audit = lazyRequire(function () { return require("../audit"); });
@@ -98,6 +99,8 @@ function _validateCidr(cidr) {
98
99
  * denyStatus: number, // default 404
99
100
  * denyBody: string, // default "Not Found"
100
101
  * audit: object,
102
+ * onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, clientIp, route }
103
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
101
104
  * }
102
105
  *
103
106
  * @example
@@ -113,7 +116,7 @@ function create(opts) {
113
116
  opts = opts || {};
114
117
  validateOpts(opts, [
115
118
  "paths", "allowedCidrs", "deniedCidrs", "trustProxy",
116
- "denyStatus", "denyBody", "audit",
119
+ "denyStatus", "denyBody", "audit", "onDeny", "problemDetails",
117
120
  ], "middleware.networkAllowlist");
118
121
 
119
122
  if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
@@ -156,6 +159,23 @@ function create(opts) {
156
159
  var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Not Found";
157
160
  var auditOn = opts.audit !== false && opts.audit != null;
158
161
  var auditInstance = opts.audit === true ? null : opts.audit; // null → use lazy-required default
162
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
163
+ var problemMode = opts.problemDetails === true;
164
+
165
+ function _deny(req, res, ip, route) {
166
+ _emitDeny(req, ip, route);
167
+ denyResponse(req, res, {
168
+ onDeny: onDeny,
169
+ problem: problemMode,
170
+ status: denyStatus,
171
+ info: { status: denyStatus, reason: "ip-not-in-allowlist", clientIp: ip, route: route },
172
+ problemCode: "network-gate-denied",
173
+ problemTitle: denyBody,
174
+ problemDetail: "Access to this resource is restricted by network policy.",
175
+ contentType: "text/plain",
176
+ body: denyBody,
177
+ });
178
+ }
159
179
 
160
180
  function _emitDeny(req, ip, route) {
161
181
  if (!auditOn) return;
@@ -196,9 +216,7 @@ function create(opts) {
196
216
  if (!ip) {
197
217
  // Fail closed: a request we can't even derive an IP for shouldn't
198
218
  // bypass the gate.
199
- _emitDeny(req, "<unknown>", pathname);
200
- res.writeHead(denyStatus, { "Content-Type": "text/plain" });
201
- res.end(denyBody);
219
+ _deny(req, res, "<unknown>", pathname);
202
220
  return;
203
221
  }
204
222
 
@@ -208,9 +226,7 @@ function create(opts) {
208
226
  for (var dii = 0; dii < deniedCidrs.length; dii++) {
209
227
  try {
210
228
  if (ssrfGuard.cidrContains(deniedCidrs[dii], ip)) {
211
- _emitDeny(req, ip, pathname);
212
- res.writeHead(denyStatus, { "Content-Type": "text/plain" });
213
- res.end(denyBody);
229
+ _deny(req, res, ip, pathname);
214
230
  return;
215
231
  }
216
232
  } catch (_e) { /* skip malformed at runtime — caught at config */ }
@@ -222,9 +238,7 @@ function create(opts) {
222
238
  } catch (_e) { /* skip malformed at runtime — caught at config */ }
223
239
  }
224
240
  if (!allowed) {
225
- _emitDeny(req, ip, pathname);
226
- res.writeHead(denyStatus, { "Content-Type": "text/plain" });
227
- res.end(denyBody);
241
+ _deny(req, res, ip, pathname);
228
242
  return;
229
243
  }
230
244
  return next();
@@ -62,6 +62,7 @@ var requestHelpers = require("../request-helpers");
62
62
  var safeAsync = require("../safe-async");
63
63
  var validateOpts = require("../validate-opts");
64
64
  var clusterStorage = require("../cluster-storage");
65
+ var denyResponse = require("./deny-response").denyResponse;
65
66
 
66
67
  var audit = lazyRequire(function () { return require("../audit"); });
67
68
  var logger = lazyRequire(function () { return require("../log").boot("rate-limit"); });
@@ -363,6 +364,8 @@ function _resolveBackend(opts) {
363
364
  * keyFn: function(req): string,
364
365
  * statusOnLimit: number, // default 429
365
366
  * bodyOnLimit: string, // default "Too Many Requests"
367
+ * onDeny: function(req, res, info): void, // own the refusal response; info = { status, reason, limit, remaining, retryAfter, key }
368
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
366
369
  * header: boolean, // default true
367
370
  * headerPrefix: string, // default "X-RateLimit-" — builds <prefix>Limit / <prefix>Remaining (e.g. "RateLimit-" for the IETF draft names)
368
371
  * skipPaths: Array<string|RegExp>,
@@ -391,7 +394,8 @@ function _resolveBackend(opts) {
391
394
  function create(opts) {
392
395
  opts = opts || {};
393
396
  validateOpts(opts, [
394
- "keyFn", "statusOnLimit", "bodyOnLimit", "header", "headerPrefix", "skipPaths", "scope",
397
+ "keyFn", "statusOnLimit", "bodyOnLimit", "onDeny", "problemDetails",
398
+ "header", "headerPrefix", "skipPaths", "scope",
395
399
  "backend", "trustProxy", "algorithm",
396
400
  // memory backend (token-bucket)
397
401
  "burst", "refillPerSecond",
@@ -404,6 +408,8 @@ function create(opts) {
404
408
  var keyFn = opts.keyFn || _clientIp;
405
409
  var statusOnLimit = opts.statusOnLimit || 429;
406
410
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
411
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
412
+ var problemMode = opts.problemDetails === true;
407
413
  var emitHeaders = opts.header !== false;
408
414
  // headerPrefix (default "X-RateLimit-") builds the limit/remaining header
409
415
  // names as <prefix>Limit / <prefix>Remaining. The X-RateLimit-* family is a
@@ -455,10 +461,21 @@ function create(opts) {
455
461
  requestId: req.requestId,
456
462
  });
457
463
  } catch (_e) { /* audit best-effort */ }
458
- if (typeof res.writeHead === "function") {
459
- res.writeHead(statusOnLimit, { "Content-Type": "text/plain" });
460
- res.end(bodyOnLimit);
461
- }
464
+ var retryAfter = verdict.retryAfter > 0 ? verdict.retryAfter : null;
465
+ denyResponse(req, res, {
466
+ onDeny: onDeny,
467
+ problem: problemMode,
468
+ status: statusOnLimit,
469
+ info: { status: statusOnLimit, reason: "rate-limit-exceeded",
470
+ limit: verdict.limit, remaining: verdict.remaining,
471
+ retryAfter: verdict.retryAfter, key: k },
472
+ problemCode: "rate-limit-exceeded",
473
+ problemTitle: "Too Many Requests",
474
+ problemDetail: "Request rate limit exceeded; retry after the indicated interval.",
475
+ problemExt: retryAfter !== null ? { retryAfter: retryAfter } : null,
476
+ contentType: "text/plain",
477
+ body: bodyOnLimit,
478
+ });
462
479
  }
463
480
 
464
481
  var middleware = function rateLimit(req, res, next) {
@@ -21,12 +21,13 @@
21
21
  var lazyRequire = require("../lazy-require");
22
22
  var requestHelpers = require("../request-helpers");
23
23
  var validateOpts = require("../validate-opts");
24
+ var denyResponse = require("./deny-response").denyResponse;
24
25
  var { AuthError } = require("../framework-error");
25
26
 
26
27
  var aal = lazyRequire(function () { return require("../auth/aal"); });
27
28
  var audit = lazyRequire(function () { return require("../audit"); });
28
29
 
29
- function _writeUnauthorized(res, requiredBand, actualBand, realm) {
30
+ function _writeUnauthorized(req, res, requiredBand, actualBand, realm, onDeny, problemMode) {
30
31
  if (res.headersSent) return;
31
32
  var body = JSON.stringify({
32
33
  error: "step_up_required",
@@ -36,14 +37,24 @@ function _writeUnauthorized(res, requiredBand, actualBand, realm) {
36
37
  });
37
38
  var realmStr = realm ? ' realm="' + realm + '"' : "";
38
39
  var challenge = "AAL-StepUp" + realmStr + ', required="' + requiredBand + '"';
39
- res.writeHead(401, { // HTTP 401 status
40
- "Content-Type": "application/json; charset=utf-8",
41
- "Content-Length": Buffer.byteLength(body),
42
- "WWW-Authenticate": challenge,
43
- // RFC 9111 §5.2.2.5 — auth-gated 401 must not be cached.
44
- "Cache-Control": "no-store",
40
+ denyResponse(req, res, {
41
+ onDeny: onDeny,
42
+ problem: problemMode,
43
+ status: 401, // HTTP 401 status
44
+ info: { status: 401, reason: "step_up_required",
45
+ required_aal: requiredBand, actual_aal: actualBand || null },
46
+ problemCode: "step-up-required",
47
+ problemTitle: "Step-Up Authentication Required",
48
+ problemDetail: "AAL " + requiredBand + " is required for this resource.",
49
+ problemExt: { required_aal: requiredBand, actual_aal: actualBand || null },
50
+ headers: {
51
+ "WWW-Authenticate": challenge,
52
+ // RFC 9111 §5.2.2.5 — auth-gated 401 must not be cached.
53
+ "Cache-Control": "no-store",
54
+ },
55
+ contentType: "application/json; charset=utf-8",
56
+ body: body,
45
57
  });
46
- res.end(body);
47
58
  }
48
59
 
49
60
  /**
@@ -68,6 +79,8 @@ function _writeUnauthorized(res, requiredBand, actualBand, realm) {
68
79
  * getAal: function(req): string,
69
80
  * realm: string,
70
81
  * audit: boolean, // default true
82
+ * onDeny: function(req, res, info): void, // own the 401; info = { status, reason, required_aal, actual_aal }
83
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
71
84
  * }
72
85
  *
73
86
  * @example
@@ -78,7 +91,7 @@ function _writeUnauthorized(res, requiredBand, actualBand, realm) {
78
91
  function create(opts) {
79
92
  opts = opts || {};
80
93
  validateOpts(opts, [
81
- "minimum", "getAal", "audit", "realm",
94
+ "minimum", "getAal", "audit", "realm", "onDeny", "problemDetails",
82
95
  ], "middleware.requireAal");
83
96
 
84
97
  var minimum = opts.minimum;
@@ -92,6 +105,8 @@ function create(opts) {
92
105
 
93
106
  var auditOn = opts.audit !== false;
94
107
  var realm = (typeof opts.realm === "string" && opts.realm.length > 0) ? opts.realm : null;
108
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
109
+ var problemMode = opts.problemDetails === true;
95
110
 
96
111
  return function requireAalMiddleware(req, res, next) {
97
112
  var actual = null;
@@ -116,7 +131,7 @@ function create(opts) {
116
131
  });
117
132
  } catch (_ignored) { /* drop-silent */ }
118
133
  }
119
- return _writeUnauthorized(res, minimum, actual, realm);
134
+ return _writeUnauthorized(req, res, minimum, actual, realm, onDeny, problemMode);
120
135
  }
121
136
 
122
137
  if (auditOn) {
@@ -39,6 +39,7 @@
39
39
  var lazyRequire = require("../lazy-require");
40
40
  var requestHelpers = require("../request-helpers");
41
41
  var validateOpts = require("../validate-opts");
42
+ var denyResponse = require("./deny-response").denyResponse;
42
43
  var audit = lazyRequire(function () { return require("../audit"); });
43
44
 
44
45
  function _defaultPrefersJson(req) {
@@ -72,6 +73,8 @@ function _defaultPrefersJson(req) {
72
73
  * prefersJson: function(req): boolean,
73
74
  * errorMessage: string, // default "Authentication required."
74
75
  * audit: boolean, // default true
76
+ * onDeny: function(req, res, info): void, // own any refusal shape; info = { status, reason, redirectTo }
77
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json for the 401 (redirect path unaffected)
75
78
  * }
76
79
  *
77
80
  * @example
@@ -83,7 +86,7 @@ function _defaultPrefersJson(req) {
83
86
  function create(opts) {
84
87
  opts = opts || {};
85
88
  validateOpts(opts, [
86
- "redirectTo", "prefersJson", "errorMessage", "audit",
89
+ "redirectTo", "prefersJson", "errorMessage", "audit", "onDeny", "problemDetails",
87
90
  ], "middleware.requireAuth");
88
91
  var redirectTo = opts.redirectTo || null;
89
92
  var prefersJson = typeof opts.prefersJson === "function"
@@ -91,6 +94,8 @@ function create(opts) {
91
94
  : _defaultPrefersJson;
92
95
  var msg = opts.errorMessage || "Authentication required.";
93
96
  var auditOn = opts.audit !== false;
97
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
98
+ var problemMode = opts.problemDetails === true;
94
99
 
95
100
  return function requireAuth(req, res, next) {
96
101
  if (req.user) return next();
@@ -107,6 +112,18 @@ function create(opts) {
107
112
  } catch (_e) { /* audit best-effort */ }
108
113
  }
109
114
 
115
+ // Operator hook owns ANY refusal shape (json / redirect / text)
116
+ // before the default content-negotiation runs.
117
+ if (onDeny) {
118
+ try {
119
+ var returned = onDeny(req, res, { status: 401, reason: "no-authenticated-user", redirectTo: redirectTo });
120
+ if (res.writableEnded) return returned;
121
+ } catch (_e) {
122
+ if (res.writableEnded) return;
123
+ // fall through to default
124
+ }
125
+ }
126
+
110
127
  // RFC 9111 §5.2.2.5 — auth-gated paths SHOULD emit
111
128
  // Cache-Control: no-store so a shared cache (or browser
112
129
  // back-button cache) can't replay a 401 / redirect / payload
@@ -115,27 +132,26 @@ function create(opts) {
115
132
  // cache directive, leaving the operator to set it themselves;
116
133
  // forgetting it under a CDN that respects Cache-Control was
117
134
  // a routine misconfiguration.
118
- if (prefersJson(req)) {
119
- if (typeof res.writeHead === "function") {
120
- res.writeHead(requestHelpers.HTTP_STATUS.UNAUTHORIZED,
121
- { "Content-Type": "application/json", "Cache-Control": "no-store" });
122
- res.end(JSON.stringify({ error: msg }));
123
- }
124
- return;
125
- }
126
- if (redirectTo) {
127
- if (typeof res.writeHead === "function") {
135
+ var wantsJson = prefersJson(req);
136
+ if (!wantsJson && redirectTo) {
137
+ if (!res.writableEnded && typeof res.writeHead === "function") {
128
138
  // 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
129
139
  res.writeHead(302, { "Location": redirectTo, "Cache-Control": "no-store" });
130
140
  res.end();
131
141
  }
132
142
  return;
133
143
  }
134
- if (typeof res.writeHead === "function") {
135
- res.writeHead(requestHelpers.HTTP_STATUS.UNAUTHORIZED,
136
- { "Content-Type": "text/plain", "Cache-Control": "no-store" });
137
- res.end(msg);
138
- }
144
+ denyResponse(req, res, {
145
+ problem: problemMode,
146
+ status: requestHelpers.HTTP_STATUS.UNAUTHORIZED,
147
+ info: { status: 401, reason: "no-authenticated-user" },
148
+ problemCode: "authentication-required",
149
+ problemTitle: "Unauthorized",
150
+ problemDetail: msg,
151
+ headers: { "Cache-Control": "no-store" },
152
+ contentType: wantsJson ? "application/json" : "text/plain",
153
+ body: wantsJson ? JSON.stringify({ error: msg }) : msg,
154
+ });
139
155
  };
140
156
  }
141
157
 
@@ -51,6 +51,7 @@
51
51
  var defineClass = require("../framework-error").defineClass;
52
52
  var lazyRequire = require("../lazy-require");
53
53
  var validateOpts = require("../validate-opts");
54
+ var denyResponse = require("./deny-response").denyResponse;
54
55
 
55
56
  var bCrypto = lazyRequire(function () { return require("../crypto"); });
56
57
  var audit = lazyRequire(function () { return require("../audit"); });
@@ -98,6 +99,8 @@ function _timingSafeStringEqual(a, b) {
98
99
  * errorMessage: string,
99
100
  * auditAction: string,
100
101
  * audit: object,
102
+ * onDeny: function(req, res, info): void, // own the refusal; info = { status, reason, ...metadata }
103
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
101
104
  * }
102
105
  *
103
106
  * @example
@@ -116,7 +119,7 @@ function create(opts) {
116
119
  validateOpts(opts, [
117
120
  "resolver", "requiredScopes", "getBoundField",
118
121
  "audit", "auditAction", "errorMessage",
119
- "tolerateMissingPeerCert",
122
+ "tolerateMissingPeerCert", "onDeny", "problemDetails",
120
123
  ], "middleware.requireBoundKey");
121
124
 
122
125
  if (typeof opts.resolver !== "function") {
@@ -150,6 +153,8 @@ function create(opts) {
150
153
  // even when peerCertFingerprints is set on the registered key.
151
154
  // Production deployments leave this at default false.
152
155
  var tolerateMissingPeerCert = !!opts.tolerateMissingPeerCert;
156
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
157
+ var problemMode = opts.problemDetails === true;
153
158
 
154
159
  function _emitAudit(outcome, metadata) {
155
160
  if (!auditOn) return;
@@ -162,30 +167,56 @@ function create(opts) {
162
167
  } catch (_e) { /* drop-silent */ }
163
168
  }
164
169
 
165
- function _refuse(res, status, reason, metadata) {
170
+ // RFC 6750 §3 the Bearer challenge carries an error code that
171
+ // matches the failure: 401 with no token presented omits the code
172
+ // entirely; an unknown / revoked token is `invalid_token`; a 403
173
+ // missing-scope is `insufficient_scope`; a malformed bound-field
174
+ // request is `invalid_request`. Server-side failures (500 / 503)
175
+ // are not authentication challenges, so they advertise the scheme
176
+ // without an (incorrect) auth-error code.
177
+ function _bearerChallenge(status, reason) {
178
+ if (status === 401) {
179
+ if (reason === "no-bearer-token") return 'Bearer realm="api"';
180
+ return 'Bearer realm="api", error="invalid_token"';
181
+ }
182
+ if (status === 403) return 'Bearer realm="api", error="insufficient_scope"';
183
+ if (status === 400) return 'Bearer realm="api", error="invalid_request"'; // HTTP 400
184
+ return 'Bearer realm="api"';
185
+ }
186
+
187
+ function _refuse(req, res, status, reason, metadata) {
166
188
  _emitAudit("denied", Object.assign({ reason: reason }, metadata || {}));
167
- if (res.writableEnded || typeof res.writeHead !== "function") return;
168
- res.writeHead(status, {
169
- "Content-Type": "application/json; charset=utf-8",
170
- "WWW-Authenticate": 'Bearer realm="api", error="invalid_request"',
171
- "Cache-Control": "no-store",
189
+ denyResponse(req, res, {
190
+ onDeny: onDeny,
191
+ problem: problemMode,
192
+ status: status,
193
+ info: Object.assign({ status: status, reason: reason }, metadata || {}),
194
+ problemCode: "bound-key-refused",
195
+ problemTitle: errorMessage,
196
+ problemDetail: "API key authentication failed: " + reason + ".",
197
+ problemExt: { reason: reason },
198
+ headers: {
199
+ "WWW-Authenticate": _bearerChallenge(status, reason),
200
+ "Cache-Control": "no-store",
201
+ },
202
+ contentType: "application/json; charset=utf-8",
203
+ body: JSON.stringify({ error: errorMessage, reason: reason }),
172
204
  });
173
- res.end(JSON.stringify({ error: errorMessage, reason: reason }));
174
205
  }
175
206
 
176
207
  return async function requireBoundKeyMiddleware(req, res, next) {
177
208
  var apiKey = _parseBearer(req);
178
- if (!apiKey) return _refuse(res, 401, "no-bearer-token", {});
209
+ if (!apiKey) return _refuse(req, res, 401, "no-bearer-token", {});
179
210
 
180
211
  var record;
181
212
  try { record = await resolver(apiKey); }
182
213
  catch (e) {
183
- return _refuse(res, 503, "resolver-unavailable", {
214
+ return _refuse(req, res, 503, "resolver-unavailable", {
184
215
  error: (e && e.message) || String(e),
185
216
  });
186
217
  }
187
218
  if (!record || typeof record !== "object") {
188
- return _refuse(res, 401, "key-unknown-or-revoked", {});
219
+ return _refuse(req, res, 401, "key-unknown-or-revoked", {});
189
220
  }
190
221
 
191
222
  // Required-scope check — operator-supplied requiredScopes must be
@@ -193,7 +224,7 @@ function create(opts) {
193
224
  var keyScopes = Array.isArray(record.scopes) ? record.scopes : [];
194
225
  for (var rsi = 0; rsi < requiredScopes.length; rsi++) {
195
226
  if (keyScopes.indexOf(requiredScopes[rsi]) === -1) {
196
- return _refuse(res, 403, "missing-scope", {
227
+ return _refuse(req, res, 403, "missing-scope", {
197
228
  requiredScope: requiredScopes[rsi], keyId: record.id || null,
198
229
  });
199
230
  }
@@ -208,25 +239,25 @@ function create(opts) {
208
239
  var fieldName = registeredKeys[bfi];
209
240
  var getter = getBoundField[fieldName];
210
241
  if (!getter) {
211
- return _refuse(res, 500, "bound-field-no-getter", {
242
+ return _refuse(req, res, 500, "bound-field-no-getter", {
212
243
  field: fieldName, keyId: record.id || null,
213
244
  });
214
245
  }
215
246
  var presented;
216
247
  try { presented = getter(req); }
217
248
  catch (e) {
218
- return _refuse(res, 400, "bound-field-getter-threw", { // HTTP 400
249
+ return _refuse(req, res, 400, "bound-field-getter-threw", { // HTTP 400
219
250
  field: fieldName, error: (e && e.message) || String(e),
220
251
  });
221
252
  }
222
253
  if (typeof presented !== "string" || presented.length === 0) {
223
- return _refuse(res, 400, "bound-field-missing", { // HTTP 400
254
+ return _refuse(req, res, 400, "bound-field-missing", { // HTTP 400
224
255
  field: fieldName, keyId: record.id || null,
225
256
  });
226
257
  }
227
258
  var expected = String(registered[fieldName]);
228
259
  if (!_timingSafeStringEqual(presented, expected)) {
229
- return _refuse(res, 403, "bound-field-mismatch", {
260
+ return _refuse(req, res, 403, "bound-field-mismatch", {
230
261
  field: fieldName, keyId: record.id || null,
231
262
  });
232
263
  }
@@ -252,7 +283,7 @@ function create(opts) {
252
283
  // Audited bypass for dev fixtures.
253
284
  _emitAudit("denied", { reason: "peer-cert-bypass-tolerated", keyId: record.id });
254
285
  } else {
255
- return _refuse(res, 401, "peer-cert-required", {
286
+ return _refuse(req, res, 401, "peer-cert-required", {
256
287
  keyId: record.id || null,
257
288
  });
258
289
  }
@@ -262,7 +293,7 @@ function create(opts) {
262
293
  // because it does the same constant-time hex/colon comparison
263
294
  // we want for an allow-list. A future refactor can rename to
264
295
  // isCertFingerprintInSet — semantically identical.
265
- return _refuse(res, 403, "peer-cert-not-pinned", {
296
+ return _refuse(req, res, 403, "peer-cert-not-pinned", {
266
297
  fingerprint: fpColon, keyId: record.id || null,
267
298
  });
268
299
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  var lazyRequire = require("../lazy-require");
21
+ var denyResponse = require("./deny-response").denyResponse;
21
22
  var { defineClass } = require("../framework-error");
22
23
 
23
24
  var RequireContentTypeError = defineClass("RequireContentTypeError", { alwaysPermanent: true });
@@ -58,8 +59,10 @@ function _normalizeAllowed(types) {
58
59
  *
59
60
  * @opts
60
61
  * {
61
- * methods: string[], // override default ["POST", "PUT", "PATCH"]
62
- * audit: boolean, // default true
62
+ * methods: string[], // override default ["POST", "PUT", "PATCH"]
63
+ * audit: boolean, // default true
64
+ * onDeny: function(req, res, info): void, // own the 415; info = { status, reason, contentType, accepted }
65
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of text/plain
63
66
  * }
64
67
  *
65
68
  * @example
@@ -82,6 +85,8 @@ function create(allowed, opts) {
82
85
  ? opts.methods.map(function (m) { return m.toUpperCase(); })
83
86
  : DEFAULT_BODY_METHODS.slice();
84
87
  var auditOn = opts.audit !== false;
88
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
89
+ var problemMode = opts.problemDetails === true;
85
90
 
86
91
  return function requireContentTypeMiddleware(req, res, next) {
87
92
  var m = (req.method || "").toUpperCase();
@@ -90,13 +95,19 @@ function create(allowed, opts) {
90
95
  var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
91
96
  if (bare.length > 0 && normalized.indexOf(bare) !== -1) return next();
92
97
  if (!res.headersSent) {
93
- var body = "Unsupported Media Type";
94
- res.writeHead(415, { // HTTP 415 status
95
- "Accept": normalized.join(", "),
96
- "Content-Type": "text/plain; charset=utf-8",
97
- "Content-Length": Buffer.byteLength(body),
98
+ denyResponse(req, res, {
99
+ onDeny: onDeny,
100
+ problem: problemMode,
101
+ status: 415,
102
+ info: { status: 415, reason: "unsupported-media-type",
103
+ contentType: bare || null, accepted: normalized },
104
+ problemCode: "unsupported-media-type",
105
+ problemTitle: "Unsupported Media Type",
106
+ problemDetail: "The request Content-Type is not accepted on this resource.",
107
+ headers: { "Accept": normalized.join(", ") },
108
+ contentType: "text/plain; charset=utf-8",
109
+ body: "Unsupported Media Type",
98
110
  });
99
- res.end(body);
100
111
  }
101
112
  if (auditOn) {
102
113
  try {