@daloyjs/core 0.16.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 (102) 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 +412 -3
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +895 -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 +41 -12
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +20 -5
  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 +205 -2
  65. package/dist/middleware.d.ts.map +1 -1
  66. package/dist/middleware.js +399 -78
  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/security.d.ts +32 -0
  81. package/dist/security.d.ts.map +1 -1
  82. package/dist/security.js +79 -0
  83. package/dist/security.js.map +1 -1
  84. package/dist/session.d.ts +43 -1
  85. package/dist/session.d.ts.map +1 -1
  86. package/dist/session.js +130 -87
  87. package/dist/session.js.map +1 -1
  88. package/dist/subdomains.d.ts +97 -0
  89. package/dist/subdomains.d.ts.map +1 -0
  90. package/dist/subdomains.js +157 -0
  91. package/dist/subdomains.js.map +1 -0
  92. package/dist/time-claims.d.ts +72 -0
  93. package/dist/time-claims.d.ts.map +1 -0
  94. package/dist/time-claims.js +88 -0
  95. package/dist/time-claims.js.map +1 -0
  96. package/dist/types.d.ts +50 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/websocket.d.ts +51 -34
  99. package/dist/websocket.d.ts.map +1 -1
  100. package/dist/websocket.js +162 -2
  101. package/dist/websocket.js.map +1 -1
  102. 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) {
@@ -212,6 +301,30 @@ export const CORS_HOOK_MARKER = Symbol.for("daloyjs.middleware.cors");
212
301
  * @since 0.16.0
213
302
  */
214
303
  export const CORS_ORIGIN_ALLOW_MARKER = Symbol.for("daloyjs.middleware.cors.originAllow");
304
+ /**
305
+ * Marker stamped on the `Hooks` object returned by {@link cors} when the
306
+ * configured `origin` permits the wildcard `"*"`. Used by the Wave 3
307
+ * boot-time refuse-to-boot guard: a wildcard CORS origin in production is
308
+ * almost always a misconfiguration, so `App` constructed with
309
+ * `secureDefaults: true` (the 0.16+ default) and resolved to
310
+ * `production` throws at `app.use(cors({ origin: "*" }))` time rather than
311
+ * silently exposing every state-changing endpoint cross-origin.
312
+ *
313
+ * @since 0.17.0
314
+ */
315
+ export const CORS_WILDCARD_ORIGIN_MARKER = Symbol.for("daloyjs.middleware.cors.wildcardOrigin");
316
+ /**
317
+ * Marker stamped on the `Hooks` object returned by {@link csrf}. Used by
318
+ * the Wave 3 boot-time refuse-to-boot guard: when `session()` is registered
319
+ * on a `secureDefaults: true` App that also exposes any state-changing
320
+ * route, the framework requires a matching `csrf()` hook somewhere in the
321
+ * hook chain so an attacker cannot forge a cross-site request that mutates
322
+ * the authenticated session. Third-party CSRF helpers can opt into the
323
+ * guard by stamping the same marker on their returned hooks.
324
+ *
325
+ * @since 0.17.0
326
+ */
327
+ export const CSRF_HOOK_MARKER = Symbol.for("daloyjs.middleware.csrf");
215
328
  /**
216
329
  * Cross-Origin Resource Sharing (CORS) middleware. Handles both preflight
217
330
  * (`OPTIONS`) and actual requests, attaching the correct
@@ -301,8 +414,28 @@ export function cors(opts) {
301
414
  };
302
415
  hooks[CORS_HOOK_MARKER] = true;
303
416
  hooks[CORS_ORIGIN_ALLOW_MARKER] = (origin) => allow(origin) !== null;
417
+ const hasWildcard = opts.origin === "*" ||
418
+ (Array.isArray(opts.origin) && opts.origin.includes("*"));
419
+ if (hasWildcard) {
420
+ hooks[CORS_WILDCARD_ORIGIN_MARKER] = true;
421
+ }
304
422
  return hooks;
305
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
+ }
306
439
  class MemoryStore {
307
440
  buckets = new Map();
308
441
  async hit(key, windowMs) {
@@ -356,7 +489,22 @@ class MemoryStore {
356
489
  * @since 0.1.0
357
490
  */
358
491
  export function rateLimit(opts) {
359
- 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}:` : "";
360
508
  const keyOf = opts.keyGenerator ??
361
509
  ((ctx) => {
362
510
  if (opts.trustProxyHeaders) {
@@ -368,7 +516,7 @@ export function rateLimit(opts) {
368
516
  });
369
517
  return {
370
518
  async beforeHandle(ctx) {
371
- const key = keyOf(ctx);
519
+ const key = `${groupPrefix}${keyOf(ctx)}`;
372
520
  const { count, resetMs } = await store.hit(key, opts.windowMs);
373
521
  const remaining = Math.max(0, opts.max - count);
374
522
  ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
@@ -382,6 +530,94 @@ export function rateLimit(opts) {
382
530
  },
383
531
  };
384
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
+ }
385
621
  // ---------- Timing ----------
386
622
  /**
387
623
  * Stamp a `Server-Timing` header (or your chosen header) on every response
@@ -413,14 +649,17 @@ export function timing(headerName = "server-timing") {
413
649
  },
414
650
  };
415
651
  }
416
- // ---------- Bearer auth helper ----------
417
652
  /**
418
653
  * Minimal Bearer-token authentication middleware. Rejects requests with no
419
654
  * `Authorization: Bearer ...` header with `401` (and a `WWW-Authenticate`
420
- * 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`.
421
657
  *
422
658
  * The `validate` callback is the integration point with whatever JWT
423
- * 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.
424
663
  *
425
664
  * @example
426
665
  * ```ts
@@ -432,14 +671,23 @@ export function timing(headerName = "server-timing") {
432
671
  * validate: async (token) => {
433
672
  * try { await jwtVerify(token, jwks); return true; } catch { return false; }
434
673
  * },
674
+ * verify: async (token, _ctx) => !(await revoked.has(hash(token))),
435
675
  * }));
436
676
  * ```
437
677
  *
438
- * @param opts - Token validator and optional `WWW-Authenticate` realm.
678
+ * @param opts - Token validator, optional revalidation hook, and realm.
439
679
  * @returns A {@link Hooks} bundle ready for `app.use(...)`.
440
680
  * @since 0.1.0
441
681
  */
442
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
+ }
443
691
  return {
444
692
  async beforeHandle(ctx) {
445
693
  const h = ctx.request.headers.get("authorization") ?? "";
@@ -453,20 +701,25 @@ export function bearerAuth(opts) {
453
701
  status: 401,
454
702
  headers: {
455
703
  "content-type": "application/problem+json",
456
- "www-authenticate": `Bearer realm="${opts.realm ?? "api"}"`,
704
+ "www-authenticate": `Bearer realm="${realm}"`,
705
+ "cache-control": "no-store",
457
706
  },
458
707
  });
459
708
  }
460
- const ok = await opts.validate(m[1]);
709
+ const ok = await options.validate(m[1]);
461
710
  if (!ok)
462
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
+ }
463
717
  return undefined;
464
718
  },
465
719
  };
466
720
  }
467
721
  const CSRF_STATE_TOKEN = "csrfToken";
468
722
  const CSRF_STATE_ISSUED = "__csrfIssued";
469
- const CSRF_COOKIE_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
470
723
  function generateCsrfToken() {
471
724
  const cryptoApi = globalThis.crypto;
472
725
  if (cryptoApi?.getRandomValues) {
@@ -478,69 +731,16 @@ function generateCsrfToken() {
478
731
  return cryptoApi.randomUUID().replace(/-/g, "");
479
732
  throw new Error("csrf(): WebCrypto is required for the default token generator. Pass a custom generator to csrf({ generator }).");
480
733
  }
481
- function validateCookieSegment(kind, value) {
482
- if (/[;\r\n\0]/.test(value))
483
- throw new Error(`csrf(): cookieOptions.${kind} contains an invalid character.`);
484
- }
485
- function validateCsrfCookieOptions(cookieName, opts) {
486
- if (!CSRF_COOKIE_NAME_RE.test(cookieName))
487
- throw new Error("csrf(): cookieName is not a valid cookie name.");
488
- if (opts.sameSite !== "Strict" && opts.sameSite !== "Lax" && opts.sameSite !== "None") {
489
- throw new Error('csrf(): cookieOptions.sameSite must be "Strict", "Lax", or "None".');
490
- }
491
- if (!opts.path.startsWith("/"))
492
- throw new Error('csrf(): cookieOptions.path must start with "/".');
493
- validateCookieSegment("path", opts.path);
494
- if (opts.domain)
495
- validateCookieSegment("domain", opts.domain);
496
- if (!Number.isInteger(opts.maxAgeSeconds) || opts.maxAgeSeconds < 0) {
497
- throw new Error("csrf(): cookieOptions.maxAgeSeconds must be a non-negative integer.");
498
- }
499
- if (cookieName.startsWith("__Host-")) {
500
- if (!opts.secure || opts.path !== "/" || opts.domain) {
501
- throw new Error('csrf(): "__Host-" cookie names require secure: true, path: "/", and no domain. ' +
502
- "Pass an explicit cookieName or relax cookieOptions to use a non-prefixed cookie.");
503
- }
504
- }
505
- if (opts.sameSite === "None" && !opts.secure) {
506
- throw new Error('csrf(): cookieOptions.sameSite: "None" requires secure: true.');
507
- }
508
- }
509
- function parseCookieValue(header, name) {
510
- if (!header)
511
- return null;
512
- const parts = header.split(";");
513
- for (let i = 0; i < parts.length; i++) {
514
- const part = parts[i];
515
- const eq = part.indexOf("=");
516
- if (eq < 0)
517
- continue;
518
- const k = part.slice(0, eq).trim();
519
- if (k === name) {
520
- const v = part.slice(eq + 1).trim();
521
- try {
522
- return decodeURIComponent(v);
523
- }
524
- catch {
525
- return v;
526
- }
527
- }
528
- }
529
- return null;
530
- }
531
- function buildCsrfSetCookie(name, value, opts) {
532
- let s = `${name}=${encodeURIComponent(value)}`;
533
- s += `; Path=${opts.path}`;
534
- s += `; SameSite=${opts.sameSite}`;
535
- if (opts.secure)
536
- s += "; Secure";
537
- if (opts.domain)
538
- s += `; Domain=${opts.domain}`;
539
- if (opts.maxAgeSeconds > 0)
540
- s += `; Max-Age=${opts.maxAgeSeconds}`;
541
- if (opts.partitioned)
542
- s += "; Partitioned";
543
- 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
+ };
544
744
  }
545
745
  /**
546
746
  * CSRF protection middleware.
@@ -627,9 +827,14 @@ export function csrf(opts = {}) {
627
827
  maxAgeSeconds: cookieOverrides.maxAgeSeconds ?? 0,
628
828
  partitioned: cookieOverrides.partitioned ?? false,
629
829
  };
630
- if (wantsDoubleSubmit)
631
- validateCsrfCookieOptions(cookieName, cookieOpts);
632
- return {
830
+ if (wantsDoubleSubmit) {
831
+ assertCookieAttributes({
832
+ scope: "csrf()",
833
+ name: cookieName,
834
+ attributes: csrfCookieAttributes(cookieOpts),
835
+ });
836
+ }
837
+ const hooks = {
633
838
  beforeHandle(ctx) {
634
839
  const method = ctx.request.method.toUpperCase();
635
840
  const isSafe = ignore.has(method);
@@ -638,7 +843,7 @@ export function csrf(opts = {}) {
638
843
  }
639
844
  if (!wantsDoubleSubmit)
640
845
  return undefined;
641
- const existing = parseCookieValue(ctx.request.headers.get("cookie"), cookieName);
846
+ const existing = readRequestCookie(ctx.request.headers.get("cookie"), cookieName);
642
847
  if (isSafe) {
643
848
  if (existing) {
644
849
  ctx.state[CSRF_STATE_TOKEN] = existing;
@@ -665,10 +870,12 @@ export function csrf(opts = {}) {
665
870
  const issued = ctx.state[CSRF_STATE_ISSUED];
666
871
  if (!issued)
667
872
  return undefined;
668
- res.headers.append("set-cookie", buildCsrfSetCookie(cookieName, issued, cookieOpts));
873
+ res.headers.append("set-cookie", serializeCookie(cookieName, issued, csrfCookieAttributes(cookieOpts)));
669
874
  return undefined;
670
875
  },
671
876
  };
877
+ hooks[CSRF_HOOK_MARKER] = true;
878
+ return hooks;
672
879
  }
673
880
  const BASIC_AUTH_TOKEN_RE = /^Basic\s+([A-Za-z0-9+/=]+)$/i;
674
881
  function decodeBasic(token) {
@@ -707,6 +914,7 @@ function basicAuthChallenge(realm) {
707
914
  headers: {
708
915
  "content-type": "application/problem+json",
709
916
  "www-authenticate": `Basic realm="${realm}", charset="UTF-8"`,
917
+ "cache-control": "no-store",
710
918
  },
711
919
  });
712
920
  }
@@ -766,9 +974,122 @@ export function basicAuth(opts) {
766
974
  return basicAuthChallenge(realm);
767
975
  ctx.state.user =
768
976
  typeof result === "object" ? result : { username: creds.user };
977
+ if (options.onAuthSuccess) {
978
+ await options.onAuthSuccess({ username: creds.user, password: creds.pass }, ctx);
979
+ }
769
980
  return undefined;
770
981
  },
771
982
  };
772
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
+ }
1088
+ return undefined;
1089
+ },
1090
+ };
1091
+ hooks[REQUIRE_SCOPES_HOOK_MARKER] = required;
1092
+ return hooks;
1093
+ }
773
1094
  export { timingSafeEqual };
774
1095
  //# sourceMappingURL=middleware.js.map