@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.
- package/README.md +3 -3
- package/dist/adapters/bun.d.ts.map +1 -1
- package/dist/adapters/bun.js +38 -7
- package/dist/adapters/bun.js.map +1 -1
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +29 -7
- package/dist/adapters/node.js.map +1 -1
- package/dist/app.d.ts +412 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +895 -29
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +218 -1
- package/dist/cli.js.map +1 -1
- package/dist/combine.d.ts +97 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +247 -0
- package/dist/combine.js.map +1 -0
- package/dist/compression.d.ts +127 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +368 -0
- package/dist/compression.js.map +1 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/conn-info.d.ts +121 -0
- package/dist/conn-info.d.ts.map +1 -0
- package/dist/conn-info.js +145 -0
- package/dist/conn-info.js.map +1 -0
- package/dist/cookie.d.ts +112 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/cookie.js +185 -0
- package/dist/cookie.js.map +1 -0
- package/dist/dependency.d.ts +47 -0
- package/dist/dependency.d.ts.map +1 -0
- package/dist/dependency.js +68 -0
- package/dist/dependency.js.map +1 -0
- package/dist/etag.d.ts +48 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/etag.js +117 -0
- package/dist/etag.js.map +1 -0
- package/dist/index.d.ts +41 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/ip-restriction.d.ts +75 -0
- package/dist/ip-restriction.d.ts.map +1 -0
- package/dist/ip-restriction.js +203 -0
- package/dist/ip-restriction.js.map +1 -0
- package/dist/jwk.d.ts +82 -0
- package/dist/jwk.d.ts.map +1 -0
- package/dist/jwk.js +269 -0
- package/dist/jwk.js.map +1 -0
- package/dist/jwt.d.ts +103 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +437 -0
- package/dist/jwt.js.map +1 -0
- package/dist/load-shedding.d.ts +73 -0
- package/dist/load-shedding.d.ts.map +1 -0
- package/dist/load-shedding.js +171 -0
- package/dist/load-shedding.js.map +1 -0
- package/dist/middleware.d.ts +205 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +399 -78
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +17 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +117 -1
- package/dist/multipart.js.map +1 -1
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +18 -1
- package/dist/openapi.js.map +1 -1
- package/dist/security-schemes.d.ts +21 -5
- package/dist/security-schemes.d.ts.map +1 -1
- package/dist/security-schemes.js +32 -5
- package/dist/security-schemes.js.map +1 -1
- package/dist/security.d.ts +32 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +79 -0
- package/dist/security.js.map +1 -1
- package/dist/session.d.ts +43 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +130 -87
- package/dist/session.js.map +1 -1
- package/dist/subdomains.d.ts +97 -0
- package/dist/subdomains.d.ts.map +1 -0
- package/dist/subdomains.js +157 -0
- package/dist/subdomains.js.map +1 -0
- package/dist/time-claims.d.ts +72 -0
- package/dist/time-claims.d.ts.map +1 -0
- package/dist/time-claims.js +88 -0
- package/dist/time-claims.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +51 -34
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +162 -2
- package/dist/websocket.js.map +1 -1
- package/package.json +31 -3
package/dist/middleware.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)`
|
|
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
|
|
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="${
|
|
704
|
+
"www-authenticate": `Bearer realm="${realm}"`,
|
|
705
|
+
"cache-control": "no-store",
|
|
457
706
|
},
|
|
458
707
|
});
|
|
459
708
|
}
|
|
460
|
-
const ok = await
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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 =
|
|
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",
|
|
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
|