@daloyjs/core 0.17.0 → 0.29.0

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 (98) hide show
  1. package/README.md +3 -3
  2. package/dist/adapters/bun.d.ts.map +1 -1
  3. package/dist/adapters/bun.js +38 -7
  4. package/dist/adapters/bun.js.map +1 -1
  5. package/dist/adapters/node.d.ts.map +1 -1
  6. package/dist/adapters/node.js +29 -7
  7. package/dist/adapters/node.js.map +1 -1
  8. package/dist/app.d.ts +322 -3
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +715 -29
  11. package/dist/app.js.map +1 -1
  12. package/dist/cli.d.ts +4 -0
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +218 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/combine.d.ts +97 -0
  17. package/dist/combine.d.ts.map +1 -0
  18. package/dist/combine.js +247 -0
  19. package/dist/combine.js.map +1 -0
  20. package/dist/compression.d.ts +127 -0
  21. package/dist/compression.d.ts.map +1 -0
  22. package/dist/compression.js +368 -0
  23. package/dist/compression.js.map +1 -0
  24. package/dist/config.d.ts +97 -0
  25. package/dist/config.d.ts.map +1 -0
  26. package/dist/config.js +132 -0
  27. package/dist/config.js.map +1 -0
  28. package/dist/conn-info.d.ts +121 -0
  29. package/dist/conn-info.d.ts.map +1 -0
  30. package/dist/conn-info.js +145 -0
  31. package/dist/conn-info.js.map +1 -0
  32. package/dist/cookie.d.ts +112 -0
  33. package/dist/cookie.d.ts.map +1 -0
  34. package/dist/cookie.js +185 -0
  35. package/dist/cookie.js.map +1 -0
  36. package/dist/dependency.d.ts +47 -0
  37. package/dist/dependency.d.ts.map +1 -0
  38. package/dist/dependency.js +68 -0
  39. package/dist/dependency.js.map +1 -0
  40. package/dist/etag.d.ts +48 -0
  41. package/dist/etag.d.ts.map +1 -0
  42. package/dist/etag.js +117 -0
  43. package/dist/etag.js.map +1 -0
  44. package/dist/index.d.ts +40 -11
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +19 -4
  47. package/dist/index.js.map +1 -1
  48. package/dist/ip-restriction.d.ts +75 -0
  49. package/dist/ip-restriction.d.ts.map +1 -0
  50. package/dist/ip-restriction.js +203 -0
  51. package/dist/ip-restriction.js.map +1 -0
  52. package/dist/jwk.d.ts +82 -0
  53. package/dist/jwk.d.ts.map +1 -0
  54. package/dist/jwk.js +269 -0
  55. package/dist/jwk.js.map +1 -0
  56. package/dist/jwt.d.ts +103 -0
  57. package/dist/jwt.d.ts.map +1 -0
  58. package/dist/jwt.js +437 -0
  59. package/dist/jwt.js.map +1 -0
  60. package/dist/load-shedding.d.ts +73 -0
  61. package/dist/load-shedding.d.ts.map +1 -0
  62. package/dist/load-shedding.js +171 -0
  63. package/dist/load-shedding.js.map +1 -0
  64. package/dist/middleware.d.ts +181 -2
  65. package/dist/middleware.d.ts.map +1 -1
  66. package/dist/middleware.js +367 -77
  67. package/dist/middleware.js.map +1 -1
  68. package/dist/multipart.d.ts +17 -0
  69. package/dist/multipart.d.ts.map +1 -1
  70. package/dist/multipart.js +117 -1
  71. package/dist/multipart.js.map +1 -1
  72. package/dist/openapi.d.ts +8 -0
  73. package/dist/openapi.d.ts.map +1 -1
  74. package/dist/openapi.js +18 -1
  75. package/dist/openapi.js.map +1 -1
  76. package/dist/security-schemes.d.ts +21 -5
  77. package/dist/security-schemes.d.ts.map +1 -1
  78. package/dist/security-schemes.js +32 -5
  79. package/dist/security-schemes.js.map +1 -1
  80. package/dist/session.d.ts +23 -1
  81. package/dist/session.d.ts.map +1 -1
  82. package/dist/session.js +106 -86
  83. package/dist/session.js.map +1 -1
  84. package/dist/subdomains.d.ts +97 -0
  85. package/dist/subdomains.d.ts.map +1 -0
  86. package/dist/subdomains.js +157 -0
  87. package/dist/subdomains.js.map +1 -0
  88. package/dist/time-claims.d.ts +72 -0
  89. package/dist/time-claims.d.ts.map +1 -0
  90. package/dist/time-claims.js +88 -0
  91. package/dist/time-claims.js.map +1 -0
  92. package/dist/types.d.ts +50 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/dist/websocket.d.ts +51 -34
  95. package/dist/websocket.d.ts.map +1 -1
  96. package/dist/websocket.js +162 -2
  97. package/dist/websocket.js.map +1 -1
  98. package/package.json +31 -3
@@ -4,6 +4,7 @@
4
4
  * All middlewares return `Hooks` objects so they compose with `app.use(...)`,
5
5
  * groups, and per-route hooks identically.
6
6
  */
7
+ import { assertCookieAttributes, readRequestCookie, serializeCookie } from "./cookie.js";
7
8
  import { TooManyRequestsError, ForbiddenError } from "./errors.js";
8
9
  import { randomId, sanitizeHeaderName, timingSafeEqual } from "./security.js";
9
10
  /**
@@ -83,12 +84,36 @@ function buildCspHeader(opt, nonce) {
83
84
  entries["trusted-types"] = trustedTypes.policies.slice();
84
85
  }
85
86
  }
87
+ if (opt.reportTo) {
88
+ entries["report-to"] = [opt.reportTo];
89
+ }
86
90
  const parts = [];
87
91
  for (const [directiveName, sources] of Object.entries(entries)) {
88
92
  parts.push(`${directiveName} ${sources.join(" ")}`);
89
93
  }
90
94
  return parts.join("; ");
91
95
  }
96
+ function cspSourceListHasValues(directiveValue) {
97
+ return Array.isArray(directiveValue)
98
+ ? directiveValue.some((source) => source.trim().length > 0)
99
+ : directiveValue.split(/\s+/).some((source) => source.length > 0);
100
+ }
101
+ function cspStringHasFrameAncestors(csp) {
102
+ return csp.split(";").some((directivePart) => {
103
+ const tokens = directivePart.trim().split(/\s+/).filter(Boolean);
104
+ const directiveName = tokens[0];
105
+ return directiveName?.toLowerCase() === "frame-ancestors" && tokens.length > 1;
106
+ });
107
+ }
108
+ function cspOptionsHaveFrameAncestors(csp) {
109
+ for (const [directiveName, directiveValue] of Object.entries(csp.directives)) {
110
+ if (directiveName.toLowerCase() === "frame-ancestors" &&
111
+ cspSourceListHasValues(directiveValue)) {
112
+ return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
92
117
  /**
93
118
  * Apply a Helmet-equivalent baseline of secure response headers:
94
119
  * `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
@@ -126,7 +151,53 @@ function buildCspHeader(opt, nonce) {
126
151
  export const SECURE_HEADERS_MARKER = Symbol.for("daloyjs.middleware.secureHeaders");
127
152
  export function secureHeaders(opts = {}) {
128
153
  const headers = {};
129
- const cspOpt = opts.contentSecurityPolicy ?? "default-src 'self'; frame-ancestors 'none'";
154
+ let cspOpt = opts.contentSecurityPolicy ?? "default-src 'self'; frame-ancestors 'none'";
155
+ // Wave 8 — refuse to construct when the developer disabled BOTH framing
156
+ // defenses simultaneously (no X-Frame-Options AND no frame-ancestors
157
+ // directive in CSP). A response with neither defense can be embedded in
158
+ // an `<iframe>` from any origin, which re-opens the clickjacking surface
159
+ // the helper is meant to close. The dual-knob "I disabled both" case is
160
+ // the documented footgun this guard catches.
161
+ const frameOptionExplicitlyDisabled = opts.frameOptions === false;
162
+ if (frameOptionExplicitlyDisabled) {
163
+ let cspProvidesFrameAncestors = false;
164
+ if (typeof cspOpt === "string") {
165
+ cspProvidesFrameAncestors = cspStringHasFrameAncestors(cspOpt);
166
+ }
167
+ else if (cspOpt !== false && typeof cspOpt === "object") {
168
+ cspProvidesFrameAncestors = cspOptionsHaveFrameAncestors(cspOpt);
169
+ }
170
+ if (!cspProvidesFrameAncestors) {
171
+ throw new Error("secureHeaders(): refusing to construct with both frameOptions: false " +
172
+ "AND no CSP frame-ancestors directive — that disables every clickjacking " +
173
+ "defense the helper provides. Set frameOptions: 'DENY' / 'SAMEORIGIN', " +
174
+ "or add a `frame-ancestors` directive to contentSecurityPolicy.");
175
+ }
176
+ }
177
+ // If `reportTo` is provided at the top level but CSP is still a string,
178
+ // promote it into a directives object so we can append `report-to <group>`.
179
+ if (opts.reportTo && typeof cspOpt === "string") {
180
+ const directives = {};
181
+ for (const part of cspOpt.split(";")) {
182
+ const trimmed = part.trim();
183
+ if (!trimmed)
184
+ continue;
185
+ const spaceIndex = trimmed.indexOf(" ");
186
+ if (spaceIndex === -1) {
187
+ directives[trimmed] = "";
188
+ }
189
+ else {
190
+ directives[trimmed.slice(0, spaceIndex)] = trimmed.slice(spaceIndex + 1);
191
+ }
192
+ }
193
+ cspOpt = { directives, reportTo: opts.reportTo };
194
+ }
195
+ else if (opts.reportTo &&
196
+ cspOpt !== false &&
197
+ typeof cspOpt === "object" &&
198
+ !cspOpt.reportTo) {
199
+ cspOpt = { ...cspOpt, reportTo: opts.reportTo };
200
+ }
130
201
  const cspIsDynamic = cspOpt !== false && typeof cspOpt === "object";
131
202
  if (cspOpt !== false && typeof cspOpt === "string") {
132
203
  headers["content-security-policy"] = cspOpt;
@@ -159,6 +230,24 @@ export function secureHeaders(opts = {}) {
159
230
  headers["x-content-type-options"] = "nosniff";
160
231
  if (opts.xssProtection ?? false)
161
232
  headers["x-xss-protection"] = "0"; // modern guidance
233
+ // Wave 4 leftover: Reporting API endpoints.
234
+ if (opts.reportingEndpoints) {
235
+ const entries = Object.entries(opts.reportingEndpoints);
236
+ if (entries.length > 0) {
237
+ // Modern: structured header field — quoted URL per group.
238
+ headers["reporting-endpoints"] = entries
239
+ .map(([group, url]) => `${group}="${url}"`)
240
+ .join(", ");
241
+ // Legacy: Report-To JSON for older Chromium versions.
242
+ headers["report-to"] = entries
243
+ .map(([group, url]) => JSON.stringify({
244
+ group,
245
+ max_age: 10886400,
246
+ endpoints: [{ url }],
247
+ }))
248
+ .join(", ");
249
+ }
250
+ }
162
251
  const hooks = {
163
252
  beforeHandle(ctx) {
164
253
  if (cspIsDynamic && cspOpt.nonce) {
@@ -332,6 +421,21 @@ export function cors(opts) {
332
421
  }
333
422
  return hooks;
334
423
  }
424
+ /**
425
+ * Shared in-memory store registry for {@link RateLimitOptions.groupId}. Two
426
+ * `rateLimit({ groupId: "auth" })` calls receive the same bucket map so the
427
+ * limit is enforced across every route the same `groupId` is mounted on.
428
+ *
429
+ * Exposed only for tests; not part of the documented public API.
430
+ *
431
+ * @internal
432
+ */
433
+ const SHARED_RATE_LIMIT_STORES = new Map();
434
+ const SHARED_LOGIN_THROTTLE_BUCKETS = new Map();
435
+ export function _resetSharedRateLimitStoresForTests() {
436
+ SHARED_RATE_LIMIT_STORES.clear();
437
+ SHARED_LOGIN_THROTTLE_BUCKETS.clear();
438
+ }
335
439
  class MemoryStore {
336
440
  buckets = new Map();
337
441
  async hit(key, windowMs) {
@@ -385,7 +489,22 @@ class MemoryStore {
385
489
  * @since 0.1.0
386
490
  */
387
491
  export function rateLimit(opts) {
388
- const store = opts.store ?? new MemoryStore();
492
+ let store;
493
+ if (opts.store) {
494
+ store = opts.store;
495
+ }
496
+ else if (opts.groupId) {
497
+ let shared = SHARED_RATE_LIMIT_STORES.get(opts.groupId);
498
+ if (!shared) {
499
+ shared = new MemoryStore();
500
+ SHARED_RATE_LIMIT_STORES.set(opts.groupId, shared);
501
+ }
502
+ store = shared;
503
+ }
504
+ else {
505
+ store = new MemoryStore();
506
+ }
507
+ const groupPrefix = opts.groupId ? `${opts.groupId}:` : "";
389
508
  const keyOf = opts.keyGenerator ??
390
509
  ((ctx) => {
391
510
  if (opts.trustProxyHeaders) {
@@ -397,7 +516,7 @@ export function rateLimit(opts) {
397
516
  });
398
517
  return {
399
518
  async beforeHandle(ctx) {
400
- const key = keyOf(ctx);
519
+ const key = `${groupPrefix}${keyOf(ctx)}`;
401
520
  const { count, resetMs } = await store.hit(key, opts.windowMs);
402
521
  const remaining = Math.max(0, opts.max - count);
403
522
  ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
@@ -411,6 +530,94 @@ export function rateLimit(opts) {
411
530
  },
412
531
  };
413
532
  }
533
+ function assertNonNegativeInteger(name, value) {
534
+ if (!Number.isInteger(value) || value < 0) {
535
+ throw new Error(`loginThrottle(): ${name} must be a non-negative integer.`);
536
+ }
537
+ }
538
+ function assertPositiveInteger(name, value) {
539
+ if (!Number.isInteger(value) || value <= 0) {
540
+ throw new Error(`loginThrottle(): ${name} must be a positive integer.`);
541
+ }
542
+ }
543
+ function defaultLoginThrottleKey(trustProxyHeaders) {
544
+ return (ctx) => {
545
+ if (trustProxyHeaders) {
546
+ const forwardedFor = ctx.request.headers.get("x-forwarded-for");
547
+ const firstForwarded = forwardedFor ? forwardedFor.split(",")[0].trim() : "";
548
+ return firstForwarded || ctx.request.headers.get("x-real-ip") || "global";
549
+ }
550
+ return "global";
551
+ };
552
+ }
553
+ function wait(ms) {
554
+ return new Promise((resolve) => setTimeout(resolve, ms));
555
+ }
556
+ /**
557
+ * Login throttle preset for `/login`, `/login/otp`, password reset, and
558
+ * adjacent credential-entry routes. It combines a shared `rateLimit({ groupId })`
559
+ * bucket with a small progressive slowdown before the hard 429 kicks in.
560
+ *
561
+ * Mount the same `loginThrottle()` instance (or multiple instances with the
562
+ * same `groupId`) across related routes so an attacker cannot bypass the limit
563
+ * by rotating between password, OTP, and reset endpoints.
564
+ *
565
+ * @since 0.23.0
566
+ */
567
+ export function loginThrottle(opts = {}) {
568
+ const windowMs = opts.windowMs ?? 15 * 60_000;
569
+ const max = opts.max ?? 5;
570
+ const delayAfter = opts.delayAfter ?? 2;
571
+ const delayMs = opts.delayMs ?? 250;
572
+ const maxDelayMs = opts.maxDelayMs ?? 2_000;
573
+ assertPositiveInteger("windowMs", windowMs);
574
+ assertPositiveInteger("max", max);
575
+ assertNonNegativeInteger("delayAfter", delayAfter);
576
+ assertNonNegativeInteger("delayMs", delayMs);
577
+ assertNonNegativeInteger("maxDelayMs", maxDelayMs);
578
+ const groupId = opts.groupId ?? "login";
579
+ const keyGenerator = opts.keyGenerator ?? defaultLoginThrottleKey(opts.trustProxyHeaders);
580
+ const limiter = rateLimit({
581
+ windowMs,
582
+ max,
583
+ groupId,
584
+ keyGenerator,
585
+ ...(opts.store ? { store: opts.store } : {}),
586
+ ...(opts.trustProxyHeaders !== undefined
587
+ ? { trustProxyHeaders: opts.trustProxyHeaders }
588
+ : {}),
589
+ ...(opts.retryAfter !== undefined ? { retryAfter: opts.retryAfter } : {}),
590
+ });
591
+ let slowdownBuckets = SHARED_LOGIN_THROTTLE_BUCKETS.get(groupId);
592
+ if (!slowdownBuckets) {
593
+ slowdownBuckets = new Map();
594
+ SHARED_LOGIN_THROTTLE_BUCKETS.set(groupId, slowdownBuckets);
595
+ }
596
+ return {
597
+ async beforeHandle(ctx) {
598
+ const now = Date.now();
599
+ const key = `${groupId}:${keyGenerator(ctx)}`;
600
+ let bucket = slowdownBuckets.get(key);
601
+ if (!bucket || bucket.resetMs <= now) {
602
+ bucket = { count: 0, resetMs: now + windowMs };
603
+ slowdownBuckets.set(key, bucket);
604
+ }
605
+ bucket.count += 1;
606
+ if (slowdownBuckets.size > 10_000) {
607
+ for (const [bucketKey, value] of slowdownBuckets) {
608
+ if (value.resetMs <= now)
609
+ slowdownBuckets.delete(bucketKey);
610
+ }
611
+ }
612
+ if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
613
+ const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
614
+ if (delay > 0)
615
+ await wait(delay);
616
+ }
617
+ return limiter.beforeHandle?.(ctx);
618
+ },
619
+ };
620
+ }
414
621
  // ---------- Timing ----------
415
622
  /**
416
623
  * Stamp a `Server-Timing` header (or your chosen header) on every response
@@ -442,14 +649,17 @@ export function timing(headerName = "server-timing") {
442
649
  },
443
650
  };
444
651
  }
445
- // ---------- Bearer auth helper ----------
446
652
  /**
447
653
  * Minimal Bearer-token authentication middleware. Rejects requests with no
448
654
  * `Authorization: Bearer ...` header with `401` (and a `WWW-Authenticate`
449
- * challenge), and requests whose token fails `validate(token)` with `403`.
655
+ * challenge), and requests whose token fails `validate(token)` (or the
656
+ * optional per-request `verify(token, ctx)` revalidation hook) with `403`.
450
657
  *
451
658
  * The `validate` callback is the integration point with whatever JWT
452
- * verifier, opaque-token introspector, or in-memory test stub you use.
659
+ * verifier, opaque-token introspector, or in-memory test stub you use. The
660
+ * optional `verify` hook (Wave 5) is the integration point for revocation
661
+ * lists, token-version counters, and other per-request invalidation checks
662
+ * that `validate` cannot answer statelessly.
453
663
  *
454
664
  * @example
455
665
  * ```ts
@@ -461,14 +671,23 @@ export function timing(headerName = "server-timing") {
461
671
  * validate: async (token) => {
462
672
  * try { await jwtVerify(token, jwks); return true; } catch { return false; }
463
673
  * },
674
+ * verify: async (token, _ctx) => !(await revoked.has(hash(token))),
464
675
  * }));
465
676
  * ```
466
677
  *
467
- * @param opts - Token validator and optional `WWW-Authenticate` realm.
678
+ * @param opts - Token validator, optional revalidation hook, and realm.
468
679
  * @returns A {@link Hooks} bundle ready for `app.use(...)`.
469
680
  * @since 0.1.0
470
681
  */
471
682
  export function bearerAuth(opts) {
683
+ const options = opts;
684
+ if (!options || typeof options.validate !== "function") {
685
+ throw new Error("bearerAuth(): validate must be a function.");
686
+ }
687
+ const realm = options.realm ?? "api";
688
+ if (/["\r\n\0]/.test(realm)) {
689
+ throw new Error("bearerAuth(): realm must not contain quotes, CR, LF, or NUL bytes.");
690
+ }
472
691
  return {
473
692
  async beforeHandle(ctx) {
474
693
  const h = ctx.request.headers.get("authorization") ?? "";
@@ -482,20 +701,25 @@ export function bearerAuth(opts) {
482
701
  status: 401,
483
702
  headers: {
484
703
  "content-type": "application/problem+json",
485
- "www-authenticate": `Bearer realm="${opts.realm ?? "api"}"`,
704
+ "www-authenticate": `Bearer realm="${realm}"`,
705
+ "cache-control": "no-store",
486
706
  },
487
707
  });
488
708
  }
489
- const ok = await opts.validate(m[1]);
709
+ const ok = await options.validate(m[1]);
490
710
  if (!ok)
491
711
  throw new ForbiddenError("Invalid token");
712
+ if (options.verify) {
713
+ const verified = await options.verify(m[1], ctx);
714
+ if (verified === false)
715
+ throw new ForbiddenError("Token revoked");
716
+ }
492
717
  return undefined;
493
718
  },
494
719
  };
495
720
  }
496
721
  const CSRF_STATE_TOKEN = "csrfToken";
497
722
  const CSRF_STATE_ISSUED = "__csrfIssued";
498
- const CSRF_COOKIE_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
499
723
  function generateCsrfToken() {
500
724
  const cryptoApi = globalThis.crypto;
501
725
  if (cryptoApi?.getRandomValues) {
@@ -507,69 +731,16 @@ function generateCsrfToken() {
507
731
  return cryptoApi.randomUUID().replace(/-/g, "");
508
732
  throw new Error("csrf(): WebCrypto is required for the default token generator. Pass a custom generator to csrf({ generator }).");
509
733
  }
510
- function validateCookieSegment(kind, value) {
511
- if (/[;\r\n\0]/.test(value))
512
- throw new Error(`csrf(): cookieOptions.${kind} contains an invalid character.`);
513
- }
514
- function validateCsrfCookieOptions(cookieName, opts) {
515
- if (!CSRF_COOKIE_NAME_RE.test(cookieName))
516
- throw new Error("csrf(): cookieName is not a valid cookie name.");
517
- if (opts.sameSite !== "Strict" && opts.sameSite !== "Lax" && opts.sameSite !== "None") {
518
- throw new Error('csrf(): cookieOptions.sameSite must be "Strict", "Lax", or "None".');
519
- }
520
- if (!opts.path.startsWith("/"))
521
- throw new Error('csrf(): cookieOptions.path must start with "/".');
522
- validateCookieSegment("path", opts.path);
523
- if (opts.domain)
524
- validateCookieSegment("domain", opts.domain);
525
- if (!Number.isInteger(opts.maxAgeSeconds) || opts.maxAgeSeconds < 0) {
526
- throw new Error("csrf(): cookieOptions.maxAgeSeconds must be a non-negative integer.");
527
- }
528
- if (cookieName.startsWith("__Host-")) {
529
- if (!opts.secure || opts.path !== "/" || opts.domain) {
530
- throw new Error('csrf(): "__Host-" cookie names require secure: true, path: "/", and no domain. ' +
531
- "Pass an explicit cookieName or relax cookieOptions to use a non-prefixed cookie.");
532
- }
533
- }
534
- if (opts.sameSite === "None" && !opts.secure) {
535
- throw new Error('csrf(): cookieOptions.sameSite: "None" requires secure: true.');
536
- }
537
- }
538
- function parseCookieValue(header, name) {
539
- if (!header)
540
- return null;
541
- const parts = header.split(";");
542
- for (let i = 0; i < parts.length; i++) {
543
- const part = parts[i];
544
- const eq = part.indexOf("=");
545
- if (eq < 0)
546
- continue;
547
- const k = part.slice(0, eq).trim();
548
- if (k === name) {
549
- const v = part.slice(eq + 1).trim();
550
- try {
551
- return decodeURIComponent(v);
552
- }
553
- catch {
554
- return v;
555
- }
556
- }
557
- }
558
- return null;
559
- }
560
- function buildCsrfSetCookie(name, value, opts) {
561
- let s = `${name}=${encodeURIComponent(value)}`;
562
- s += `; Path=${opts.path}`;
563
- s += `; SameSite=${opts.sameSite}`;
564
- if (opts.secure)
565
- s += "; Secure";
566
- if (opts.domain)
567
- s += `; Domain=${opts.domain}`;
568
- if (opts.maxAgeSeconds > 0)
569
- s += `; Max-Age=${opts.maxAgeSeconds}`;
570
- if (opts.partitioned)
571
- s += "; Partitioned";
572
- return s;
734
+ function csrfCookieAttributes(opts) {
735
+ return {
736
+ sameSite: opts.sameSite,
737
+ secure: opts.secure,
738
+ path: opts.path,
739
+ domain: opts.domain || undefined,
740
+ maxAgeSeconds: opts.maxAgeSeconds,
741
+ partitioned: opts.partitioned,
742
+ httpOnly: false,
743
+ };
573
744
  }
574
745
  /**
575
746
  * CSRF protection middleware.
@@ -656,8 +827,13 @@ export function csrf(opts = {}) {
656
827
  maxAgeSeconds: cookieOverrides.maxAgeSeconds ?? 0,
657
828
  partitioned: cookieOverrides.partitioned ?? false,
658
829
  };
659
- if (wantsDoubleSubmit)
660
- validateCsrfCookieOptions(cookieName, cookieOpts);
830
+ if (wantsDoubleSubmit) {
831
+ assertCookieAttributes({
832
+ scope: "csrf()",
833
+ name: cookieName,
834
+ attributes: csrfCookieAttributes(cookieOpts),
835
+ });
836
+ }
661
837
  const hooks = {
662
838
  beforeHandle(ctx) {
663
839
  const method = ctx.request.method.toUpperCase();
@@ -667,7 +843,7 @@ export function csrf(opts = {}) {
667
843
  }
668
844
  if (!wantsDoubleSubmit)
669
845
  return undefined;
670
- const existing = parseCookieValue(ctx.request.headers.get("cookie"), cookieName);
846
+ const existing = readRequestCookie(ctx.request.headers.get("cookie"), cookieName);
671
847
  if (isSafe) {
672
848
  if (existing) {
673
849
  ctx.state[CSRF_STATE_TOKEN] = existing;
@@ -694,7 +870,7 @@ export function csrf(opts = {}) {
694
870
  const issued = ctx.state[CSRF_STATE_ISSUED];
695
871
  if (!issued)
696
872
  return undefined;
697
- res.headers.append("set-cookie", buildCsrfSetCookie(cookieName, issued, cookieOpts));
873
+ res.headers.append("set-cookie", serializeCookie(cookieName, issued, csrfCookieAttributes(cookieOpts)));
698
874
  return undefined;
699
875
  },
700
876
  };
@@ -738,6 +914,7 @@ function basicAuthChallenge(realm) {
738
914
  headers: {
739
915
  "content-type": "application/problem+json",
740
916
  "www-authenticate": `Basic realm="${realm}", charset="UTF-8"`,
917
+ "cache-control": "no-store",
741
918
  },
742
919
  });
743
920
  }
@@ -797,9 +974,122 @@ export function basicAuth(opts) {
797
974
  return basicAuthChallenge(realm);
798
975
  ctx.state.user =
799
976
  typeof result === "object" ? result : { username: creds.user };
977
+ if (options.onAuthSuccess) {
978
+ await options.onAuthSuccess({ username: creds.user, password: creds.pass }, ctx);
979
+ }
980
+ return undefined;
981
+ },
982
+ };
983
+ }
984
+ // ---------- requireScopes (Wave 5 leftover) ----------
985
+ /**
986
+ * Marker stamped on the per-request `state` bag so multiple `requireScopes()`
987
+ * hooks in the same chain aggregate their required scopes into one combined
988
+ * `WWW-Authenticate: Bearer scope="..."` challenge instead of each emitting a
989
+ * separate `401` with only its own scopes.
990
+ *
991
+ * @since 0.21.0
992
+ */
993
+ export const REQUIRE_SCOPES_AGGREGATE_KEY = "__daloyRequiredScopes";
994
+ export const REQUIRE_SCOPES_HOOK_MARKER = Symbol.for("daloyjs.middleware.requireScopes");
995
+ function validateScopeList(scopes) {
996
+ if (!Array.isArray(scopes) || scopes.length === 0) {
997
+ throw new Error("requireScopes(): scopes must be a non-empty array of strings.");
998
+ }
999
+ const seen = new Set();
1000
+ const out = [];
1001
+ for (const s of scopes) {
1002
+ if (typeof s !== "string" || s.length === 0) {
1003
+ throw new Error("requireScopes(): every scope must be a non-empty string.");
1004
+ }
1005
+ // RFC 6749 §3.3: scope values must not contain double-quote, backslash, or
1006
+ // control characters; rejecting them at construction time means the
1007
+ // WWW-Authenticate challenge cannot be malformed by user input.
1008
+ if (/["\\\x00-\x1F\x7F]/.test(s)) {
1009
+ throw new Error(`requireScopes(): scope "${s}" contains an illegal character.`);
1010
+ }
1011
+ if (!seen.has(s)) {
1012
+ seen.add(s);
1013
+ out.push(s);
1014
+ }
1015
+ }
1016
+ return out;
1017
+ }
1018
+ function readUserScopes(user) {
1019
+ if (!user || typeof user !== "object")
1020
+ return null;
1021
+ const raw = user.scopes;
1022
+ if (!Array.isArray(raw))
1023
+ return null;
1024
+ const set = new Set();
1025
+ for (const s of raw)
1026
+ if (typeof s === "string" && s.length > 0)
1027
+ set.add(s);
1028
+ return set;
1029
+ }
1030
+ /**
1031
+ * Declarative scope-check middleware for OAuth2-style bearer credentials.
1032
+ * Reads the typed `ctx.state.user.scopes` written by the upstream auth
1033
+ * helper (`bearerAuth`, `jwt`, …) and refuses the request with `401` (no
1034
+ * credentials) or `403` (valid credentials but insufficient scopes).
1035
+ *
1036
+ * Multiple `requireScopes()` hooks in the same chain aggregate their required
1037
+ * scopes into one combined `WWW-Authenticate: Bearer scope="a b c"` challenge
1038
+ * via {@link REQUIRE_SCOPES_AGGREGATE_KEY} on `ctx.state`.
1039
+ *
1040
+ * @example
1041
+ * ```ts
1042
+ * app.route({
1043
+ * method: "POST",
1044
+ * path: "/items",
1045
+ * hooks: { ...bearerAuth({ validate }), ...requireScopes(["items:write"]) },
1046
+ * responses: { 200: { description: "ok" } },
1047
+ * handler: () => ({ status: 200 as const, body: { ok: true } }),
1048
+ * });
1049
+ * ```
1050
+ *
1051
+ * @since 0.21.0
1052
+ */
1053
+ export function requireScopes(scopes) {
1054
+ const required = validateScopeList(scopes);
1055
+ const hooks = {
1056
+ beforeHandle(ctx) {
1057
+ const state = ctx.state;
1058
+ const prior = state[REQUIRE_SCOPES_AGGREGATE_KEY];
1059
+ const aggregate = Array.isArray(prior) ? [...prior] : [];
1060
+ for (const s of required)
1061
+ if (!aggregate.includes(s))
1062
+ aggregate.push(s);
1063
+ state[REQUIRE_SCOPES_AGGREGATE_KEY] = aggregate;
1064
+ const user = state.user;
1065
+ if (user === undefined || user === null) {
1066
+ const challenge = `Bearer scope="${aggregate.join(" ")}", error="insufficient_scope"`;
1067
+ return new Response(JSON.stringify({
1068
+ type: "https://daloyjs.dev/errors/unauthorized",
1069
+ title: "Unauthorized",
1070
+ status: 401,
1071
+ detail: `Missing credentials. Required scopes: ${aggregate.join(", ")}.`,
1072
+ }), {
1073
+ status: 401,
1074
+ headers: {
1075
+ "content-type": "application/problem+json",
1076
+ "www-authenticate": challenge,
1077
+ "cache-control": "no-store",
1078
+ },
1079
+ });
1080
+ }
1081
+ const owned = readUserScopes(user);
1082
+ const missing = owned === null
1083
+ ? aggregate.slice()
1084
+ : aggregate.filter((s) => !owned.has(s));
1085
+ if (missing.length > 0) {
1086
+ throw new ForbiddenError(`Missing required scope(s): ${missing.join(", ")}.`);
1087
+ }
800
1088
  return undefined;
801
1089
  },
802
1090
  };
1091
+ hooks[REQUIRE_SCOPES_HOOK_MARKER] = required;
1092
+ return hooks;
803
1093
  }
804
1094
  export { timingSafeEqual };
805
1095
  //# sourceMappingURL=middleware.js.map