@nifrajs/middleware 4.0.2 → 5.1.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 -0
- package/dist/_utils.d.ts +15 -9
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +39 -40
- package/dist/_utils.js.map +1 -1
- package/dist/admission.d.ts +4 -2
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js +17 -5
- package/dist/admission.js.map +1 -1
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +8 -13
- package/dist/basic-auth.js.map +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +4 -2
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +7 -4
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +58 -25
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +58 -1
- package/dist/compression.js.map +1 -1
- package/dist/conditional.d.ts.map +1 -1
- package/dist/conditional.js +1 -1
- package/dist/conditional.js.map +1 -1
- package/dist/content-negotiation.d.ts.map +1 -1
- package/dist/content-negotiation.js +3 -1
- package/dist/content-negotiation.js.map +1 -1
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +10 -5
- package/dist/csrf.js.map +1 -1
- package/dist/healthcheck.d.ts.map +1 -1
- package/dist/healthcheck.js +3 -1
- package/dist/healthcheck.js.map +1 -1
- package/dist/idempotency.d.ts +20 -4
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +44 -17
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +17 -2
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +70 -31
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +12 -0
- package/dist/language.js.map +1 -1
- package/dist/openapi.d.ts +19 -4
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +24 -8
- package/dist/openapi.js.map +1 -1
- package/dist/rate-limit.d.ts +31 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +160 -26
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +1 -1
- package/dist/request-id.js.map +1 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +1 -1
- package/dist/timing.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +8 -13
- package/dist/token-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +61 -35
- package/src/admission.ts +15 -5
- package/src/basic-auth.ts +15 -13
- package/src/cache-control.ts +5 -2
- package/src/cache.ts +68 -28
- package/src/compression.ts +84 -4
- package/src/conditional.ts +8 -4
- package/src/content-negotiation.ts +3 -1
- package/src/csrf.ts +10 -6
- package/src/healthcheck.ts +4 -1
- package/src/idempotency.ts +77 -20
- package/src/index.ts +3 -0
- package/src/jwt.ts +106 -34
- package/src/language.ts +11 -0
- package/src/openapi.ts +52 -10
- package/src/rate-limit.ts +233 -41
- package/src/request-id.ts +7 -3
- package/src/timing.ts +8 -4
- package/src/token-auth.ts +18 -16
package/src/csrf.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import {
|
|
2
|
+
import { isRegistrableMethod } from "@nifrajs/core/methods"
|
|
3
|
+
import { isSameOriginRequest, type Middleware } from "@nifrajs/core/server"
|
|
3
4
|
import {
|
|
4
5
|
base64UrlEncode,
|
|
5
6
|
guardName,
|
|
@@ -78,6 +79,10 @@ function originAllowed(req: Request, origins: Set<string> | undefined): boolean
|
|
|
78
79
|
const allows = (candidate: string): boolean =>
|
|
79
80
|
origins !== undefined ? origins.has(candidate) : isSameOriginRequest(candidate, req)
|
|
80
81
|
const origin = req.headers.get("origin")
|
|
82
|
+
// Under `Referrer-Policy: no-referrer` a browser sends a same-origin form POST with `Origin: null`;
|
|
83
|
+
// its own `Sec-Fetch-Site` still says same-origin, and page script can set neither header.
|
|
84
|
+
if (origin === "null" && origins === undefined)
|
|
85
|
+
return req.headers.get("sec-fetch-site") === "same-origin"
|
|
81
86
|
if (origin !== null) return allows(origin)
|
|
82
87
|
|
|
83
88
|
const referer = req.headers.get("referer")
|
|
@@ -170,7 +175,7 @@ export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promi
|
|
|
170
175
|
export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promise<boolean> {
|
|
171
176
|
const keys = csrfKeys(secret)
|
|
172
177
|
const parts = token.split(".")
|
|
173
|
-
//
|
|
178
|
+
// nifra-expect NF-S002: TOKEN_PREFIX is a public, non-secret format tag; this equality is a shape
|
|
174
179
|
// check that short-circuits malformed input. The secret comparison is the constant-time
|
|
175
180
|
// verifyHmacSha256 over the signature below.
|
|
176
181
|
if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || parts[1] === "" || parts[2] === "") {
|
|
@@ -230,9 +235,8 @@ export function csrf(options: CsrfOptions): Middleware {
|
|
|
230
235
|
scope: "global",
|
|
231
236
|
// Middleware may intentionally mention methods the Nifra router does not expose (for example
|
|
232
237
|
// TRACE). Keep runtime behavior unchanged, but publish evidence only for registerable routes.
|
|
233
|
-
methods
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
: METHODS.filter((method) => methods?.has(method)),
|
|
238
|
+
...(methods === undefined
|
|
239
|
+
? { exceptMethods: [...SAFE_METHODS].filter(isRegistrableMethod) }
|
|
240
|
+
: { methods: [...methods].filter(isRegistrableMethod) }),
|
|
237
241
|
})
|
|
238
242
|
}
|
package/src/healthcheck.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface HealthcheckOptions {
|
|
|
14
14
|
|
|
15
15
|
const noStore = { "cache-control": "no-store" } as const
|
|
16
16
|
|
|
17
|
+
// Named per call, so a second, differently configured instance applies instead of being deduped.
|
|
18
|
+
let instances = 0
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* Register **liveness** (`/health`) and **readiness** (`/ready`) endpoints. Liveness is a flat `200`
|
|
19
22
|
* (the process is serving). Readiness runs each `check` and returns `200 { status: "ok", checks }`
|
|
@@ -34,7 +37,7 @@ export function healthcheck(options: HealthcheckOptions = {}): IdentityPlugin {
|
|
|
34
37
|
// A router plugin: the probes are registered as a side effect and the app comes back with its type
|
|
35
38
|
// unchanged, so routes declared after `.use(healthcheck())` keep theirs. The probes are deliberately
|
|
36
39
|
// absent from the caller's typed registry - they are operational endpoints, not part of the contract.
|
|
37
|
-
return defineRouterPlugin(
|
|
40
|
+
return defineRouterPlugin(`healthcheck#${++instances}`, (app) => {
|
|
38
41
|
app.register("GET", path, undefined, () =>
|
|
39
42
|
Response.json({ status: "ok" }, { headers: noStore }),
|
|
40
43
|
)
|
package/src/idempotency.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isAuthenticatedRequest,
|
|
4
|
+
METHODS,
|
|
5
|
+
type Middleware,
|
|
6
|
+
replacedRequestOf,
|
|
7
|
+
} from "@nifrajs/core/server"
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
|
|
6
11
|
* tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
|
|
7
|
-
* running twice (double-charge, double-publish).
|
|
8
|
-
*
|
|
12
|
+
* running twice (double-charge, double-publish). The key is claimed in `onRequest`, so an in-flight
|
|
13
|
+
* collision short-circuits before any mutation; a replay waits for `beforeHandle`, so a credential
|
|
14
|
+
* revoked since the first request gets the route's current auth answer, not the stored response.
|
|
15
|
+
* That needs the route declared after `.use(idempotency())` - `beforeHandle` is order-scoped.
|
|
9
16
|
*
|
|
10
17
|
* Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of
|
|
11
18
|
* truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
|
|
@@ -19,6 +26,12 @@ export interface IdempotencyRecord {
|
|
|
19
26
|
readonly headers: ReadonlyArray<readonly [string, string]>
|
|
20
27
|
/** Response body, base64-encoded. */
|
|
21
28
|
readonly body: string
|
|
29
|
+
/**
|
|
30
|
+
* The response came from a route this middleware's `beforeHandle` covers, so a retry is replayed
|
|
31
|
+
* from there - after the route's authentication, validation and earlier guards accept it again. A
|
|
32
|
+
* record without it (a route declared before `.use(idempotency())`) is replayed in `onRequest`.
|
|
33
|
+
*/
|
|
34
|
+
readonly recheck?: boolean
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
export type IdempotencyClaim =
|
|
@@ -192,8 +205,11 @@ export interface IdempotencyOptions {
|
|
|
192
205
|
readonly maxBytes?: number
|
|
193
206
|
/**
|
|
194
207
|
* Whether a response should be cached for replay. Default: any status below 500 except 401, 403,
|
|
195
|
-
* 408, 409, 425, and 429
|
|
196
|
-
*
|
|
208
|
+
* 408, 409, 425, and 429, and the request rejections 400, 404, 405, 413, 414, 415, 422 and 431.
|
|
209
|
+
* Like a transient 5xx, those say the operation did not run: replaying one would refuse every
|
|
210
|
+
* retry under that key until it expired, and storing a rejection lets a caller fill the store
|
|
211
|
+
* with keys sent to a route that does not exist. A handler that answers 400 or 422 after a side
|
|
212
|
+
* effect should supply its own predicate.
|
|
197
213
|
*/
|
|
198
214
|
readonly shouldCache?: (response: Response) => boolean
|
|
199
215
|
/**
|
|
@@ -218,12 +234,19 @@ export interface IdempotencyOptions {
|
|
|
218
234
|
* same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh
|
|
219
235
|
* key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable
|
|
220
236
|
* principal id to scope by; that is strictly better than digesting a whole `Cookie` header.
|
|
237
|
+
*
|
|
238
|
+
* A request that carries none of these headers is keyed as anonymous. If an authentication stage
|
|
239
|
+
* then accepts it (a credential in another header), its response is not stored: under the
|
|
240
|
+
* anonymous key any caller presenting the same `Idempotency-Key` would be replayed it. Add that
|
|
241
|
+
* header here to dedupe such requests.
|
|
221
242
|
*/
|
|
222
243
|
readonly principalHeaders?: readonly string[]
|
|
223
244
|
}
|
|
224
245
|
|
|
225
246
|
const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
|
|
226
|
-
const NOT_THE_OPERATIONS_ANSWER = new Set([
|
|
247
|
+
const NOT_THE_OPERATIONS_ANSWER = new Set([
|
|
248
|
+
400, 401, 403, 404, 405, 408, 409, 413, 414, 415, 422, 425, 429, 431,
|
|
249
|
+
])
|
|
227
250
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
228
251
|
|
|
229
252
|
function assertPositiveTtl(value: number, name: string): void {
|
|
@@ -364,6 +387,20 @@ function replay(record: IdempotencyRecord): Response {
|
|
|
364
387
|
return new Response(body === null ? null : body.slice(), { status: record.status, headers })
|
|
365
388
|
}
|
|
366
389
|
|
|
390
|
+
/** The request `onRequest` recorded in `map`, walking back past a later hook's replacement of it
|
|
391
|
+
* (methodOverride, a transport codec). */
|
|
392
|
+
function recordedRequest<T>(
|
|
393
|
+
map: WeakMap<Request, T>,
|
|
394
|
+
req: Request,
|
|
395
|
+
): readonly [Request, T] | undefined {
|
|
396
|
+
for (let current: Request | undefined = req; current !== undefined; ) {
|
|
397
|
+
const found = map.get(current)
|
|
398
|
+
if (found !== undefined) return [current, found]
|
|
399
|
+
current = replacedRequestOf(current)
|
|
400
|
+
}
|
|
401
|
+
return undefined
|
|
402
|
+
}
|
|
403
|
+
|
|
367
404
|
const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
|
|
368
405
|
|
|
369
406
|
/** SHA-256 of the caller's credential headers, base64url. Only the digest ever becomes part of a store
|
|
@@ -440,7 +477,13 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
440
477
|
? (req: Request): Promise<string | null> | null =>
|
|
441
478
|
defaultIdempotencyKey(req, header, principalHeaders)
|
|
442
479
|
: (req: Request): string | null | Promise<string | null> => custom(req, header)
|
|
443
|
-
const claimed = new WeakMap<
|
|
480
|
+
const claimed = new WeakMap<
|
|
481
|
+
Request,
|
|
482
|
+
{ readonly key: string; readonly reservation: string; readonly anonymous: boolean }
|
|
483
|
+
>()
|
|
484
|
+
// Replays held for `beforeHandle`, and fresh claims whose route `beforeHandle` reached.
|
|
485
|
+
const deferred = new WeakMap<Request, IdempotencyRecord>()
|
|
486
|
+
const rechecked = new WeakSet<Request>()
|
|
444
487
|
|
|
445
488
|
const middleware: Middleware = {
|
|
446
489
|
name: "idempotency",
|
|
@@ -461,7 +504,11 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
461
504
|
headers: { "content-type": "application/json", "retry-after": "1" },
|
|
462
505
|
})
|
|
463
506
|
}
|
|
464
|
-
if (claim.state === "replay")
|
|
507
|
+
if (claim.state === "replay") {
|
|
508
|
+
if (claim.record.recheck !== true) return replay(claim.record)
|
|
509
|
+
deferred.set(req, claim.record)
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
465
512
|
if (claim.state === "in_flight") {
|
|
466
513
|
return new Response(JSON.stringify({ ok: false, error: "idempotency_in_progress" }), {
|
|
467
514
|
status: 409,
|
|
@@ -472,21 +519,31 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
472
519
|
})
|
|
473
520
|
}
|
|
474
521
|
if (claim.state !== "new") return undefined
|
|
475
|
-
|
|
522
|
+
const anonymous =
|
|
523
|
+
custom === undefined && principalHeaders.every((name) => req.headers.get(name) === null)
|
|
524
|
+
claimed.set(req, { key, reservation: claim.reservation, anonymous })
|
|
476
525
|
return undefined
|
|
477
526
|
},
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
claimedReq = replacedRequestOf(claimedReq)
|
|
485
|
-
if (claimedReq !== undefined) claim = claimed.get(claimedReq)
|
|
527
|
+
beforeHandle(context) {
|
|
528
|
+
// Authentication and validation have accepted this retry; only now is the stored answer its own.
|
|
529
|
+
const held = recordedRequest(deferred, context.req)
|
|
530
|
+
if (held !== undefined) {
|
|
531
|
+
deferred.delete(held[0])
|
|
532
|
+
return replay(held[1])
|
|
486
533
|
}
|
|
487
|
-
|
|
534
|
+
const fresh = recordedRequest(claimed, context.req)
|
|
535
|
+
if (fresh !== undefined) rechecked.add(fresh[0])
|
|
536
|
+
return undefined
|
|
537
|
+
},
|
|
538
|
+
async onResponse(res, req) {
|
|
539
|
+
const found = recordedRequest(claimed, req)
|
|
540
|
+
if (found === undefined) return res // not a claimed request
|
|
541
|
+
const [claimedReq, claim] = found
|
|
488
542
|
claimed.delete(claimedReq)
|
|
489
|
-
if (
|
|
543
|
+
if (
|
|
544
|
+
!shouldCache(res) ||
|
|
545
|
+
(claim.anonymous && (isAuthenticatedRequest(req) || isAuthenticatedRequest(claimedReq)))
|
|
546
|
+
) {
|
|
490
547
|
await store.release(claim.key, claim.reservation)
|
|
491
548
|
return res
|
|
492
549
|
}
|
|
@@ -507,7 +564,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
507
564
|
await store.complete(
|
|
508
565
|
claim.key,
|
|
509
566
|
claim.reservation,
|
|
510
|
-
{ status: res.status, headers, body: toBase64(bytes) },
|
|
567
|
+
{ status: res.status, headers, body: toBase64(bytes), recheck: rechecked.has(claimedReq) },
|
|
511
568
|
ttlMs,
|
|
512
569
|
)
|
|
513
570
|
return captured.response
|
package/src/index.ts
CHANGED
|
@@ -127,7 +127,10 @@ export {
|
|
|
127
127
|
type RateLimitOptions,
|
|
128
128
|
type RateLimitResult,
|
|
129
129
|
type RateLimitStore,
|
|
130
|
+
type RateLimitTier,
|
|
131
|
+
type RateLimitTiersOptions,
|
|
130
132
|
rateLimit,
|
|
133
|
+
rateLimitTiers,
|
|
131
134
|
} from "./rate-limit.ts"
|
|
132
135
|
export { type RequestIdOptions, requestId } from "./request-id.ts"
|
|
133
136
|
export { type SecurityHeadersOptions, securityHeaders } from "./security-headers.ts"
|
package/src/jwt.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
base64UrlDecode,
|
|
5
5
|
bearerToken,
|
|
6
6
|
guardName,
|
|
7
|
+
type HeaderGet,
|
|
8
|
+
installAuthGuard,
|
|
7
9
|
jsonError,
|
|
8
10
|
type MaybePromise,
|
|
9
11
|
parseCookies,
|
|
@@ -88,12 +90,27 @@ export interface JwksOptions {
|
|
|
88
90
|
readonly fetch?: typeof fetch
|
|
89
91
|
readonly cacheMs?: number
|
|
90
92
|
/**
|
|
91
|
-
* Extra time to keep using the last successful key set
|
|
92
|
-
*
|
|
93
|
+
* Extra time to keep using the last successful key set past `cacheMs`. Inside it a request is
|
|
94
|
+
* answered from that set at once while one refresh runs behind it, so a slow or failing identity
|
|
95
|
+
* provider holds no request. Defaults to one cache window; set `0` to wait for every refresh and
|
|
96
|
+
* fail closed on refresh errors.
|
|
93
97
|
*/
|
|
94
98
|
readonly staleMs?: number
|
|
95
99
|
readonly timeoutMs?: number
|
|
96
100
|
readonly maxBytes?: number
|
|
101
|
+
/**
|
|
102
|
+
* Least time (ms) between the refreshes a token with a `kid` the cached set lacks may start, so a
|
|
103
|
+
* newly rotated key is picked up without made-up `kid`s turning every request into a fetch.
|
|
104
|
+
* Default 30 s.
|
|
105
|
+
*/
|
|
106
|
+
readonly cooldownMs?: number
|
|
107
|
+
/**
|
|
108
|
+
* Least time (ms) between fetches after one that failed or returned no keys. Inside it a request
|
|
109
|
+
* that needs the set gets that outcome again at once (the same error, or no key) instead of
|
|
110
|
+
* waiting up to `timeoutMs` on a fetch of its own, so an identity provider outage does not hold
|
|
111
|
+
* every request. Default 5 s; `0` fetches again for every such request.
|
|
112
|
+
*/
|
|
113
|
+
readonly failureCooldownMs?: number
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
const HASH: Record<JwtAlgorithm, "SHA-256" | "SHA-384" | "SHA-512"> = {
|
|
@@ -300,15 +317,19 @@ export async function tryVerifyJwt<C extends JwtClaims = JwtClaims>(
|
|
|
300
317
|
}
|
|
301
318
|
}
|
|
302
319
|
|
|
303
|
-
function
|
|
304
|
-
|
|
320
|
+
function tokenFromHeaders(
|
|
321
|
+
get: HeaderGet,
|
|
322
|
+
header: string,
|
|
323
|
+
cookie: string | undefined,
|
|
324
|
+
): string | null {
|
|
325
|
+
const headerValue = get(header)
|
|
305
326
|
if (headerValue !== null) {
|
|
306
327
|
if (header === "authorization") {
|
|
307
328
|
return bearerToken(headerValue)
|
|
308
329
|
}
|
|
309
330
|
return headerValue.trim() || null
|
|
310
331
|
}
|
|
311
|
-
return cookie === undefined ? null : (parseCookies(
|
|
332
|
+
return cookie === undefined ? null : (parseCookies(get("cookie"))[cookie] ?? null)
|
|
312
333
|
}
|
|
313
334
|
|
|
314
335
|
function challenge(realm: string): string {
|
|
@@ -327,17 +348,23 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
|
|
|
327
348
|
const store = new WeakMap<Request, C>()
|
|
328
349
|
|
|
329
350
|
const plugin = defineIdentityPlugin(guardName("jwt"), (app) =>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
installAuthGuard(
|
|
352
|
+
app,
|
|
353
|
+
"jwt",
|
|
354
|
+
optional,
|
|
355
|
+
store,
|
|
356
|
+
async (get) => {
|
|
357
|
+
const token = tokenFromHeaders(get, header, options.cookie)
|
|
358
|
+
if (token === null) return null
|
|
359
|
+
try {
|
|
360
|
+
return (await verifyJwt<C>(token, options)).claims
|
|
361
|
+
} catch {
|
|
362
|
+
// A token that fails verification is no credential: a 401, never a 503.
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
() => reject(realm),
|
|
367
|
+
),
|
|
341
368
|
)
|
|
342
369
|
const instrumented = optional
|
|
343
370
|
? plugin
|
|
@@ -429,6 +456,12 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
|
|
|
429
456
|
const staleMs = options.staleMs ?? cacheMs
|
|
430
457
|
const timeoutMs = options.timeoutMs ?? 5_000
|
|
431
458
|
const maxBytes = options.maxBytes ?? 65_536
|
|
459
|
+
const cooldownMs = options.cooldownMs ?? 30_000
|
|
460
|
+
const failureCooldownMs = options.failureCooldownMs ?? 5_000
|
|
461
|
+
if (!Number.isFinite(cooldownMs) || cooldownMs < 0)
|
|
462
|
+
throw new Error("jwks: cooldownMs must be non-negative")
|
|
463
|
+
if (!Number.isFinite(failureCooldownMs) || failureCooldownMs < 0)
|
|
464
|
+
throw new Error("jwks: failureCooldownMs must be non-negative")
|
|
432
465
|
if (!Number.isFinite(cacheMs) || cacheMs < 0)
|
|
433
466
|
throw new Error("jwks: cacheMs must be non-negative")
|
|
434
467
|
if (!Number.isFinite(staleMs) || staleMs < 0)
|
|
@@ -440,30 +473,69 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
|
|
|
440
473
|
}
|
|
441
474
|
let cached: readonly JwkKey[] = []
|
|
442
475
|
let cachedAt = 0
|
|
476
|
+
let refreshedAt = Number.NEGATIVE_INFINITY
|
|
443
477
|
let pending: Promise<readonly JwkKey[]> | undefined
|
|
478
|
+
// When the last fetch failed or returned no keys, and its error (none for an empty set).
|
|
479
|
+
let failedAt = Number.NEGATIVE_INFINITY
|
|
480
|
+
let failure: { readonly error: unknown } | undefined
|
|
481
|
+
|
|
482
|
+
const refresh = (): Promise<readonly JwkKey[]> => {
|
|
483
|
+
if (pending === undefined) {
|
|
484
|
+
refreshedAt = Date.now()
|
|
485
|
+
pending = fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes })
|
|
486
|
+
.then(
|
|
487
|
+
(keys) => {
|
|
488
|
+
cached = keys
|
|
489
|
+
cachedAt = Date.now()
|
|
490
|
+
failedAt = keys.length === 0 ? cachedAt : Number.NEGATIVE_INFINITY
|
|
491
|
+
failure = undefined
|
|
492
|
+
return keys
|
|
493
|
+
},
|
|
494
|
+
(error: unknown) => {
|
|
495
|
+
failedAt = Date.now()
|
|
496
|
+
failure = { error }
|
|
497
|
+
throw error
|
|
498
|
+
},
|
|
499
|
+
)
|
|
500
|
+
.finally(() => {
|
|
501
|
+
pending = undefined
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
return pending
|
|
505
|
+
}
|
|
444
506
|
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
return cached
|
|
458
|
-
}
|
|
459
|
-
throw error
|
|
507
|
+
const coolingDown = (): boolean =>
|
|
508
|
+
pending === undefined && Date.now() - failedAt < failureCooldownMs
|
|
509
|
+
|
|
510
|
+
const load = (): Promise<readonly JwkKey[]> => {
|
|
511
|
+
const age = Date.now() - cachedAt
|
|
512
|
+
if (cached.length > 0 && age < cacheMs) return Promise.resolve(cached)
|
|
513
|
+
if (cached.length > 0 && age < cacheMs + staleMs) {
|
|
514
|
+
if (!coolingDown()) refresh().catch(() => {})
|
|
515
|
+
return Promise.resolve(cached)
|
|
516
|
+
}
|
|
517
|
+
if (coolingDown()) {
|
|
518
|
+
return failure === undefined ? Promise.resolve(cached) : Promise.reject(failure.error)
|
|
460
519
|
}
|
|
520
|
+
return refresh()
|
|
461
521
|
}
|
|
462
522
|
|
|
463
523
|
return async (header) => {
|
|
464
|
-
|
|
465
|
-
|
|
524
|
+
const kid = header.kid
|
|
525
|
+
if (typeof kid !== "string" || kid === "") return null
|
|
466
526
|
const alg = header.alg as JwtAlgorithm
|
|
467
|
-
|
|
527
|
+
const find = (keys: readonly JwkKey[]): JwkKey | undefined =>
|
|
528
|
+
keys.find((key) => key.kid === kid && validateJwkForAlg(key, alg, kid))
|
|
529
|
+
const found = find(await load())
|
|
530
|
+
if (found !== undefined) return found
|
|
531
|
+
// A kid the set lacks may be a key the provider just rotated in.
|
|
532
|
+
if (coolingDown() || (pending === undefined && Date.now() - refreshedAt < cooldownMs)) {
|
|
533
|
+
return undefined
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
return find(await refresh())
|
|
537
|
+
} catch {
|
|
538
|
+
return undefined
|
|
539
|
+
}
|
|
468
540
|
}
|
|
469
541
|
}
|
package/src/language.ts
CHANGED
|
@@ -81,6 +81,15 @@ export function pickLanguage<const L extends readonly string[]>(
|
|
|
81
81
|
return { language: defaultLanguage, matched: "default" }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** `value` naming each of `names` once; `Vary: *` already covers every request header. */
|
|
85
|
+
function varyOn(value: string | null, names: readonly string[]): string {
|
|
86
|
+
if (value === null || value.trim() === "") return names.join(", ")
|
|
87
|
+
const fields = value.split(",").map((field) => field.trim().toLowerCase())
|
|
88
|
+
if (fields.includes("*")) return value
|
|
89
|
+
const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
|
|
90
|
+
return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
/**
|
|
85
94
|
* Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
|
|
86
95
|
*
|
|
@@ -114,6 +123,8 @@ export function language<const L extends readonly string[]>(
|
|
|
114
123
|
.use(
|
|
115
124
|
withResponseObserver<Middleware>({
|
|
116
125
|
onResponseHeaders(headers, req) {
|
|
126
|
+
// The language is chosen from Accept-Language, so a shared cache must key on it.
|
|
127
|
+
headers.set("vary", varyOn(headers.get("vary"), ["Accept-Language"]))
|
|
117
128
|
if (!emitHeader) return
|
|
118
129
|
if (headers.has("content-language")) return
|
|
119
130
|
const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
|
package/src/openapi.ts
CHANGED
|
@@ -11,7 +11,12 @@ export interface RouteLike {
|
|
|
11
11
|
// `| undefined` (not just `?`) so a core `RouteDescriptor` - whose `schema` is a *required*
|
|
12
12
|
// `RouteSchema | undefined` - is assignable under `exactOptionalPropertyTypes`.
|
|
13
13
|
readonly schema?:
|
|
14
|
-
| {
|
|
14
|
+
| {
|
|
15
|
+
readonly body?: unknown
|
|
16
|
+
readonly query?: unknown
|
|
17
|
+
readonly response?: unknown
|
|
18
|
+
readonly sse?: unknown
|
|
19
|
+
}
|
|
15
20
|
| undefined
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -40,11 +45,23 @@ export interface OpenApiUiOptions {
|
|
|
40
45
|
readonly path?: string
|
|
41
46
|
/** Page title. Defaults to `info.title`. */
|
|
42
47
|
readonly title?: string
|
|
43
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The Scalar script URL. Default: `@scalar/api-reference` 1.72.1's standalone build on jsDelivr,
|
|
50
|
+
* pinned by {@link integrity}. Point it at a copy you serve yourself (no third-party origin to
|
|
51
|
+
* allow in a `script-src` policy, nothing to trust but your own deploy).
|
|
52
|
+
*/
|
|
44
53
|
readonly cdn?: string
|
|
54
|
+
/**
|
|
55
|
+
* The script's Subresource Integrity hash (`sha384-...`), which makes the browser refuse a script
|
|
56
|
+
* whose bytes differ. Default: the default build's hash when `cdn` is unset, none when it is set.
|
|
57
|
+
* Pass the hash of your own copy, or `false` to send none.
|
|
58
|
+
*/
|
|
59
|
+
readonly integrity?: string | false
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
export interface OpenApiOptions {
|
|
63
|
+
/** The OpenAPI version to declare. `"3.2.0"` describes each SSE event with the standard `itemSchema`. */
|
|
64
|
+
readonly openapiVersion?: "3.1.0" | "3.2.0"
|
|
48
65
|
readonly info?: OpenApiInfo
|
|
49
66
|
readonly servers?: readonly OpenApiServer[]
|
|
50
67
|
/** Tag definitions (top-level `tags`). Reference them from an operation via `operations`. */
|
|
@@ -91,6 +108,7 @@ export function buildOpenApiDocument(
|
|
|
91
108
|
// and the shim only satisfies it structurally.
|
|
92
109
|
const appShim = { routes: () => included } as unknown as Parameters<typeof toOpenAPI>[0]
|
|
93
110
|
return toOpenAPI(appShim, {
|
|
111
|
+
...(options.openapiVersion !== undefined ? { openapiVersion: options.openapiVersion } : {}),
|
|
94
112
|
title: options.info?.title ?? "nifra API",
|
|
95
113
|
version: options.info?.version ?? "0.0.0",
|
|
96
114
|
...(options.info?.description !== undefined ? { description: options.info.description } : {}),
|
|
@@ -102,16 +120,34 @@ export function buildOpenApiDocument(
|
|
|
102
120
|
}) as unknown as Record<string, unknown>
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
// An exact version, so the page always loads the bytes SCALAR_INTEGRITY names: a bare package URL
|
|
124
|
+
// follows `latest`, and anyone who can publish to it could run script on the API's own origin.
|
|
125
|
+
const SCALAR_CDN =
|
|
126
|
+
"https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js"
|
|
127
|
+
// SHA-384 of that file, the same from the npm tarball (checked against its registry integrity) and
|
|
128
|
+
// from jsDelivr. Bump both together.
|
|
129
|
+
const SCALAR_INTEGRITY = "sha384-U11tb2XnKvmwt8RlTvnwUnYgrN+ur4Xyh9htLhjajWNR/Oyl5AX5DEz00qRmlrmK"
|
|
130
|
+
|
|
131
|
+
// Named per call, so a second, differently configured instance applies instead of being deduped.
|
|
132
|
+
let instances = 0
|
|
106
133
|
|
|
107
134
|
/**
|
|
108
135
|
* Minimal HTML that mounts the Scalar API reference against a spec URL. The interpolated values are
|
|
109
136
|
* dev-provided config (not request input), but they're still escaped so a stray quote or `<` can't
|
|
110
137
|
* break the markup.
|
|
111
138
|
*/
|
|
112
|
-
function scalarPage(
|
|
139
|
+
function scalarPage(
|
|
140
|
+
specUrl: string,
|
|
141
|
+
title: string,
|
|
142
|
+
cdn: string,
|
|
143
|
+
integrity: string | undefined,
|
|
144
|
+
): string {
|
|
113
145
|
const attr = (s: string): string => s.replace(/&/g, "&").replace(/"/g, """)
|
|
114
146
|
const text = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<")
|
|
147
|
+
// SRI on a cross-origin script needs a CORS fetch; without a hash, a plain fetch keeps a copy
|
|
148
|
+
// served without CORS headers loading.
|
|
149
|
+
const pinned =
|
|
150
|
+
integrity === undefined ? "" : ` integrity="${attr(integrity)}" crossorigin="anonymous"`
|
|
115
151
|
return `<!doctype html>
|
|
116
152
|
<html>
|
|
117
153
|
<head>
|
|
@@ -121,7 +157,7 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
|
|
|
121
157
|
</head>
|
|
122
158
|
<body>
|
|
123
159
|
<script id="api-reference" data-url="${attr(specUrl)}"></script>
|
|
124
|
-
<script src="${attr(cdn)}"></script>
|
|
160
|
+
<script src="${attr(cdn)}"${pinned}></script>
|
|
125
161
|
</body>
|
|
126
162
|
</html>`
|
|
127
163
|
}
|
|
@@ -132,9 +168,11 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
|
|
|
132
168
|
* **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
|
|
133
169
|
* registered - so the plugin's own position in the chain doesn't matter.
|
|
134
170
|
*
|
|
135
|
-
* Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page
|
|
136
|
-
* `/reference`) that renders the spec.
|
|
137
|
-
*
|
|
171
|
+
* Pass `ui: true` (or `ui: { path, title, cdn, integrity }`) to also serve a Scalar API-reference page
|
|
172
|
+
* (default `/reference`) that renders the spec. By default the page loads an exact Scalar version from
|
|
173
|
+
* jsDelivr with a Subresource Integrity hash, so a changed file is refused rather than run on your
|
|
174
|
+
* origin. Self-host it via `ui.cdn` (with your copy's `ui.integrity`), and allow the script's origin if
|
|
175
|
+
* you ship a `script-src` Content-Security-Policy.
|
|
138
176
|
*
|
|
139
177
|
* ```ts
|
|
140
178
|
* app.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
|
|
@@ -149,7 +187,7 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
|
|
|
149
187
|
let uiCached: string | undefined
|
|
150
188
|
// A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
|
|
151
189
|
// app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
|
|
152
|
-
return defineRouterPlugin(
|
|
190
|
+
return defineRouterPlugin(`openapi#${++instances}`, (app) => {
|
|
153
191
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
154
192
|
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
155
193
|
app.register("GET", docPath, undefined, () => {
|
|
@@ -166,8 +204,12 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
|
|
|
166
204
|
if (ui !== undefined && uiPath !== undefined) {
|
|
167
205
|
const title = ui.title ?? options.info?.title ?? "nifra API"
|
|
168
206
|
const cdn = ui.cdn ?? SCALAR_CDN
|
|
207
|
+
const integrity =
|
|
208
|
+
ui.integrity === false
|
|
209
|
+
? undefined
|
|
210
|
+
: (ui.integrity ?? (ui.cdn === undefined ? SCALAR_INTEGRITY : undefined))
|
|
169
211
|
app.register("GET", uiPath, undefined, () => {
|
|
170
|
-
uiCached ??= scalarPage(docPath, title, cdn)
|
|
212
|
+
uiCached ??= scalarPage(docPath, title, cdn, integrity)
|
|
171
213
|
return new Response(uiCached, { headers: { "content-type": "text/html; charset=utf-8" } })
|
|
172
214
|
})
|
|
173
215
|
}
|