@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.
Files changed (86) hide show
  1. package/README.md +3 -0
  2. package/dist/_utils.d.ts +15 -9
  3. package/dist/_utils.d.ts.map +1 -1
  4. package/dist/_utils.js +39 -40
  5. package/dist/_utils.js.map +1 -1
  6. package/dist/admission.d.ts +4 -2
  7. package/dist/admission.d.ts.map +1 -1
  8. package/dist/admission.js +17 -5
  9. package/dist/admission.js.map +1 -1
  10. package/dist/basic-auth.d.ts.map +1 -1
  11. package/dist/basic-auth.js +8 -13
  12. package/dist/basic-auth.js.map +1 -1
  13. package/dist/cache-control.d.ts.map +1 -1
  14. package/dist/cache-control.js +4 -2
  15. package/dist/cache-control.js.map +1 -1
  16. package/dist/cache.d.ts +7 -4
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +58 -25
  19. package/dist/cache.js.map +1 -1
  20. package/dist/compression.d.ts.map +1 -1
  21. package/dist/compression.js +58 -1
  22. package/dist/compression.js.map +1 -1
  23. package/dist/conditional.d.ts.map +1 -1
  24. package/dist/conditional.js +1 -1
  25. package/dist/conditional.js.map +1 -1
  26. package/dist/content-negotiation.d.ts.map +1 -1
  27. package/dist/content-negotiation.js +3 -1
  28. package/dist/content-negotiation.js.map +1 -1
  29. package/dist/csrf.d.ts.map +1 -1
  30. package/dist/csrf.js +10 -5
  31. package/dist/csrf.js.map +1 -1
  32. package/dist/healthcheck.d.ts.map +1 -1
  33. package/dist/healthcheck.js +3 -1
  34. package/dist/healthcheck.js.map +1 -1
  35. package/dist/idempotency.d.ts +20 -4
  36. package/dist/idempotency.d.ts.map +1 -1
  37. package/dist/idempotency.js +44 -17
  38. package/dist/idempotency.js.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/jwt.d.ts +17 -2
  44. package/dist/jwt.d.ts.map +1 -1
  45. package/dist/jwt.js +70 -31
  46. package/dist/jwt.js.map +1 -1
  47. package/dist/language.d.ts.map +1 -1
  48. package/dist/language.js +12 -0
  49. package/dist/language.js.map +1 -1
  50. package/dist/openapi.d.ts +19 -4
  51. package/dist/openapi.d.ts.map +1 -1
  52. package/dist/openapi.js +24 -8
  53. package/dist/openapi.js.map +1 -1
  54. package/dist/rate-limit.d.ts +31 -0
  55. package/dist/rate-limit.d.ts.map +1 -1
  56. package/dist/rate-limit.js +160 -26
  57. package/dist/rate-limit.js.map +1 -1
  58. package/dist/request-id.d.ts.map +1 -1
  59. package/dist/request-id.js +1 -1
  60. package/dist/request-id.js.map +1 -1
  61. package/dist/timing.d.ts.map +1 -1
  62. package/dist/timing.js +1 -1
  63. package/dist/timing.js.map +1 -1
  64. package/dist/token-auth.d.ts.map +1 -1
  65. package/dist/token-auth.js +8 -13
  66. package/dist/token-auth.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/_utils.ts +61 -35
  69. package/src/admission.ts +15 -5
  70. package/src/basic-auth.ts +15 -13
  71. package/src/cache-control.ts +5 -2
  72. package/src/cache.ts +68 -28
  73. package/src/compression.ts +84 -4
  74. package/src/conditional.ts +8 -4
  75. package/src/content-negotiation.ts +3 -1
  76. package/src/csrf.ts +10 -6
  77. package/src/healthcheck.ts +4 -1
  78. package/src/idempotency.ts +77 -20
  79. package/src/index.ts +3 -0
  80. package/src/jwt.ts +106 -34
  81. package/src/language.ts +11 -0
  82. package/src/openapi.ts +52 -10
  83. package/src/rate-limit.ts +233 -41
  84. package/src/request-id.ts +7 -3
  85. package/src/timing.ts +8 -4
  86. package/src/token-auth.ts +18 -16
package/src/rate-limit.ts CHANGED
@@ -164,6 +164,27 @@ export interface RateLimitOptions {
164
164
  * when none is declared).
165
165
  */
166
166
  readonly key?: (req: Request, platform?: Platform) => string
167
+ /**
168
+ * Leave a request uncharged: it is not counted and carries no `RateLimit-*` headers. Runs before
169
+ * the key is derived, so a health check or an internal probe never touches the store.
170
+ */
171
+ readonly skip?: (req: Request, platform?: Platform) => boolean
172
+ }
173
+
174
+ /** One bucket of a {@link rateLimitTiers} limiter: the requests at `prefix` or below it. */
175
+ export interface RateLimitTier {
176
+ /** Static path prefix such as `/api/public`: no params, wildcards, dot segments or trailing slash. */
177
+ readonly prefix: string
178
+ readonly max: number
179
+ readonly windowMs: number
180
+ /** Bucket key for this tier only. Default: the limiter's `key`, or the derived client IP. */
181
+ readonly key?: (req: Request, platform?: Platform) => string
182
+ }
183
+
184
+ export interface RateLimitTiersOptions extends Omit<RateLimitOptions, "max" | "windowMs"> {
185
+ /** The bucket for every request no tier claims. */
186
+ readonly floor: { readonly max: number; readonly windowMs: number }
187
+ readonly tiers: readonly RateLimitTier[]
167
188
  }
168
189
 
169
190
  /**
@@ -230,49 +251,76 @@ function nativeKey(
230
251
  return allowGlobalKey ? "global" : null
231
252
  }
232
253
 
233
- /**
234
- * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
235
- * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
236
- * `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
237
- */
238
- export function rateLimit(options: RateLimitOptions): Middleware {
239
- const { store, max, windowMs } = options
240
- if (!Number.isInteger(max) || max < 1)
241
- throw new Error("rateLimit: max must be a positive integer")
254
+ interface QuotaInfo {
255
+ readonly limit: number
256
+ readonly remaining: number
257
+ readonly resetSeconds: number
258
+ }
259
+
260
+ /** A counter a request can be charged to. `scope` prefixes its store keys so buckets never share one. */
261
+ interface Bucket {
262
+ readonly max: number
263
+ readonly windowMs: number
264
+ readonly scope: string
265
+ readonly key: (req: Request, platform?: Platform) => string | null
266
+ readonly nativeKey: ((req: NodeRequestContext, platform?: Platform) => string | null) | undefined
267
+ }
268
+
269
+ function assertWindow(max: number, windowMs: number, label: string): void {
270
+ if (!Number.isInteger(max) || max < 1) throw new Error(`${label}: max must be a positive integer`)
242
271
  if (!Number.isFinite(windowMs) || windowMs <= 0) {
243
- throw new Error("rateLimit: windowMs must be a positive number")
272
+ throw new Error(`${label}: windowMs must be a positive number`)
244
273
  }
274
+ }
275
+
276
+ /** The shared keying options, validated once, as the default key pair every bucket starts from. */
277
+ function defaultKeys(
278
+ options: RateLimitOptions | RateLimitTiersOptions,
279
+ label: string,
280
+ ): Pick<Bucket, "key" | "nativeKey"> {
245
281
  const trustedProxies = options.trustedProxies ?? 0
246
282
  if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
247
- throw new Error("rateLimit: trustedProxies must be a non-negative integer")
283
+ throw new Error(`${label}: trustedProxies must be a non-negative integer`)
248
284
  }
249
285
  const header = options.header?.trim().toLowerCase()
250
- if (header !== undefined && header.trim() === "") throw new Error("rateLimit: header is empty")
286
+ if (header !== undefined && header.trim() === "") throw new Error(`${label}: header is empty`)
251
287
  const allowGlobalKey = options.allowGlobalKey === true
252
- const keyOf =
253
- options.key ??
254
- ((req: Request, platform?: Platform) =>
255
- defaultKey(req, platform, trustedProxies, header, allowGlobalKey))
256
- const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
288
+ if (options.key !== undefined) return { key: options.key, nativeKey: undefined }
289
+ return {
290
+ key: (req, platform) => defaultKey(req, platform, trustedProxies, header, allowGlobalKey),
291
+ nativeKey: (req, platform) => nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The limiter both public factories build: `bucketFor` picks the one bucket a request is charged to,
297
+ * from its pathname when `pathAware`. The Node twins exist only when every bucket can key the
298
+ * allocation-light native request and nothing needs a real `Request` (`skip`, a custom `key`).
299
+ */
300
+ function limiter(
301
+ name: string,
302
+ store: RateLimitStore,
303
+ buckets: readonly Bucket[],
304
+ bucketFor: (pathname: string) => Bucket,
305
+ pathAware: boolean,
306
+ skip: RateLimitOptions["skip"],
307
+ ): Middleware {
308
+ const quota = new WeakMap<Request, QuotaInfo>()
257
309
  // State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
258
310
  // to the request and response twins - the core identity contract that replaces `Request` keying).
259
- const nativeQuota = new WeakMap<object, { remaining: number; resetSeconds: number }>()
260
-
261
- interface QuotaInfo {
262
- readonly remaining: number
263
- readonly resetSeconds: number
264
- }
311
+ const nativeQuota = new WeakMap<object, QuotaInfo>()
265
312
 
266
- const hitStore = async (
313
+ const charge = async (
314
+ bucket: Bucket,
267
315
  key: string | null,
268
316
  ): Promise<{ info: QuotaInfo; reject: Response | undefined } | Response> => {
269
317
  if (typeof key !== "string" || key.trim() === "") {
270
318
  return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
271
319
  }
272
- const { count, resetAt } = await store.hit(key, windowMs)
320
+ const { count, resetAt } = await store.hit(bucket.scope + key, bucket.windowMs)
273
321
  const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
274
- const info = { remaining: Math.max(0, max - count), resetSeconds }
275
- if (count > max) {
322
+ const info = { limit: bucket.max, remaining: Math.max(0, bucket.max - count), resetSeconds }
323
+ if (count > bucket.max) {
276
324
  return {
277
325
  info,
278
326
  reject: new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
@@ -284,10 +332,13 @@ export function rateLimit(options: RateLimitOptions): Middleware {
284
332
  return { info, reject: undefined }
285
333
  }
286
334
 
335
+ const native = skip === undefined && buckets.every((bucket) => bucket.nativeKey !== undefined)
287
336
  const middleware: Middleware = {
288
- name: guardName("rate-limit"),
337
+ name,
289
338
  async onRequest(req, platform) {
290
- const outcome = await hitStore(keyOf(req, platform))
339
+ if (skip?.(req, platform) === true) return undefined
340
+ const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
341
+ const outcome = await charge(bucket, bucket.key(req, platform))
291
342
  if (outcome instanceof Response) return outcome
292
343
  quota.set(req, outcome.info)
293
344
  return outcome.reject
@@ -296,20 +347,21 @@ export function rateLimit(options: RateLimitOptions): Middleware {
296
347
  const info = quota.get(req)
297
348
  if (info === undefined) return res
298
349
  quota.delete(req)
299
- return withHeaders(res, (headers) => {
300
- headers.set("RateLimit-Limit", String(max))
301
- headers.set("RateLimit-Remaining", String(info.remaining))
302
- headers.set("RateLimit-Reset", String(info.resetSeconds))
303
- })
350
+ return withHeaders(
351
+ res,
352
+ (headers) => {
353
+ headers.set("RateLimit-Limit", String(info.limit))
354
+ headers.set("RateLimit-Remaining", String(info.remaining))
355
+ headers.set("RateLimit-Reset", String(info.resetSeconds))
356
+ },
357
+ req,
358
+ )
304
359
  },
305
- // Node twins exist only for the built-in header/XFF key derivation - a custom `key` callback
306
- // takes a real `Request`, which is exactly the object the native lane avoids building.
307
- ...(options.key === undefined
360
+ ...(native
308
361
  ? {
309
362
  onNodeRequest: async (req: NodeRequestContext, platform?: Platform) => {
310
- const outcome = await hitStore(
311
- nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
312
- )
363
+ const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
364
+ const outcome = await charge(bucket, bucket.nativeKey?.(req, platform) ?? null)
313
365
  if (outcome instanceof Response) return outcome
314
366
  nativeQuota.set(req, outcome.info)
315
367
  return outcome.reject
@@ -320,16 +372,156 @@ export function rateLimit(options: RateLimitOptions): Middleware {
320
372
  nativeQuota.delete(req)
321
373
  // Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so this
322
374
  // is byte-identical output.
323
- setNodeHeader(res, "ratelimit-limit", String(max))
375
+ setNodeHeader(res, "ratelimit-limit", String(info.limit))
324
376
  setNodeHeader(res, "ratelimit-remaining", String(info.remaining))
325
377
  setNodeHeader(res, "ratelimit-reset", String(info.resetSeconds))
326
378
  },
327
379
  }
328
380
  : {}),
329
381
  }
382
+ return middleware
383
+ }
384
+
385
+ /**
386
+ * The pathname the router matches: an absolute URL's path or a raw request target, without query
387
+ * or fragment. A dot segment is resolved the way the server resolves it before routing.
388
+ */
389
+ function pathnameOf(url: string): string {
390
+ let start = 0
391
+ if (!url.startsWith("/")) {
392
+ const authority = url.indexOf("//")
393
+ const slash = url.indexOf("/", authority === -1 ? 0 : authority + 2)
394
+ if (slash === -1) return "/"
395
+ start = slash
396
+ }
397
+ let end = url.length
398
+ const query = url.indexOf("?", start)
399
+ if (query !== -1) end = query
400
+ const hash = url.indexOf("#", start)
401
+ if (hash !== -1 && hash < end) end = hash
402
+ const path = url.slice(start, end)
403
+ return DOT_SEGMENT.test(path) ? new URL(path, "http://n").pathname : path
404
+ }
405
+
406
+ const DOT_SEGMENT = /\/(?:\.|%2e)/i
407
+
408
+ /**
409
+ * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
410
+ * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
411
+ * `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
412
+ */
413
+ export function rateLimit(options: RateLimitOptions): Middleware {
414
+ assertWindow(options.max, options.windowMs, "rateLimit")
415
+ if (options.skip !== undefined && typeof options.skip !== "function") {
416
+ throw new TypeError("rateLimit: skip must be a function")
417
+ }
418
+ const bucket: Bucket = {
419
+ max: options.max,
420
+ windowMs: options.windowMs,
421
+ scope: "",
422
+ ...defaultKeys(options, "rateLimit"),
423
+ }
424
+ const middleware = limiter(
425
+ guardName("rate-limit"),
426
+ options.store,
427
+ [bucket],
428
+ () => bucket,
429
+ false,
430
+ options.skip,
431
+ )
330
432
  return withRouteAssurance(middleware, {
331
433
  id: NIFRA_ASSURANCE.RATE_LIMITED,
332
434
  source: "rate-limit",
333
435
  scope: "global",
334
436
  })
335
437
  }
438
+
439
+ const STATIC_PREFIX = /^(?:\/[A-Za-z0-9\-._~%!$&'()+,;=@]+)+$/
440
+
441
+ function assertTierPrefix(prefix: unknown): asserts prefix is string {
442
+ const bad = (why: string): never => {
443
+ throw new Error(`rateLimitTiers: tier prefix ${JSON.stringify(prefix)} ${why}`)
444
+ }
445
+ if (typeof prefix !== "string" || !STATIC_PREFIX.test(prefix)) {
446
+ return bad('must be a static path such as "/api/public", with no trailing slash')
447
+ }
448
+ for (const segment of prefix.split("/").slice(1)) {
449
+ if (segment === "." || segment === "..") bad("must not contain a dot segment")
450
+ if (segment.startsWith(":") || segment.startsWith("*"))
451
+ bad("must be static: no params or wildcards")
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Rate limiting in tiers under one app-wide floor, as a single {@link Middleware}. Every request is
457
+ * charged to exactly one bucket: the tier with the longest `prefix` that is the request's path or an
458
+ * ancestor of it, else the `floor`. Tiers never stack, so a tier may sit above the floor
459
+ * (`600/min` for `/api/public` under a `120/min` floor). It runs in `onRequest`, before routing, so
460
+ * a 404, a 405 or a body that fails validation is charged too. Prefixes are validated here; each
461
+ * tier counts in its own keys, so tiers can share one store.
462
+ */
463
+ export function rateLimitTiers(options: RateLimitTiersOptions): Middleware {
464
+ const label = "rateLimitTiers"
465
+ if (options.floor === null || typeof options.floor !== "object") {
466
+ throw new TypeError(`${label}: floor must be { max, windowMs }`)
467
+ }
468
+ if (!Array.isArray(options.tiers)) throw new TypeError(`${label}: tiers must be an array`)
469
+ if (options.skip !== undefined && typeof options.skip !== "function") {
470
+ throw new TypeError(`${label}: skip must be a function`)
471
+ }
472
+ assertWindow(options.floor.max, options.floor.windowMs, `${label} floor`)
473
+ const keys = defaultKeys(options, label)
474
+ const floor: Bucket = {
475
+ max: options.floor.max,
476
+ windowMs: options.floor.windowMs,
477
+ scope: "floor:",
478
+ ...keys,
479
+ }
480
+ const seen = new Set<string>()
481
+ const tiers: (Bucket & { readonly prefix: string })[] = []
482
+ for (const tier of options.tiers) {
483
+ assertTierPrefix(tier.prefix)
484
+ if (seen.has(tier.prefix)) {
485
+ throw new Error(`${label}: duplicate tier prefix ${JSON.stringify(tier.prefix)}`)
486
+ }
487
+ seen.add(tier.prefix)
488
+ assertWindow(tier.max, tier.windowMs, `${label} tier ${tier.prefix}`)
489
+ if (tier.key !== undefined && typeof tier.key !== "function") {
490
+ throw new TypeError(`${label}: tier ${tier.prefix} key must be a function`)
491
+ }
492
+ tiers.push({
493
+ prefix: tier.prefix,
494
+ max: tier.max,
495
+ windowMs: tier.windowMs,
496
+ scope: `tier:${tier.prefix}:`,
497
+ key: tier.key ?? keys.key,
498
+ nativeKey: tier.key === undefined ? keys.nativeKey : undefined,
499
+ })
500
+ }
501
+ // Longest first, so the first tier whose prefix holds the path is the most specific one.
502
+ tiers.sort((a, b) => b.prefix.length - a.prefix.length)
503
+ const bucketFor = (pathname: string): Bucket => {
504
+ for (const tier of tiers) {
505
+ if (
506
+ pathname.startsWith(tier.prefix) &&
507
+ (pathname.length === tier.prefix.length || pathname.charCodeAt(tier.prefix.length) === 47)
508
+ ) {
509
+ return tier
510
+ }
511
+ }
512
+ return floor
513
+ }
514
+ const middleware = limiter(
515
+ guardName("rate-limit-tiers"),
516
+ options.store,
517
+ [floor, ...tiers],
518
+ bucketFor,
519
+ true,
520
+ options.skip,
521
+ )
522
+ return withRouteAssurance(middleware, {
523
+ id: NIFRA_ASSURANCE.RATE_LIMITED,
524
+ source: "rate-limit-tiers",
525
+ scope: "global",
526
+ })
527
+ }
package/src/request-id.ts CHANGED
@@ -65,9 +65,13 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
65
65
  // The id the derive generated; else the inbound header, then a fresh id - so a response
66
66
  // no derive saw (a 404, an earlier middleware's short-circuit) is still covered.
67
67
  const echo = id ?? inboundOf(req.headers.get(header)) ?? generate()
68
- return withHeaders(res, (headers) => {
69
- headers.set(header, echo)
70
- })
68
+ return withHeaders(
69
+ res,
70
+ (headers) => {
71
+ headers.set(header, echo)
72
+ },
73
+ req,
74
+ )
71
75
  },
72
76
  onNodeResponse(res, req) {
73
77
  // The common node path (a handler route, whose derive wrote the id through `c.set`) pays
package/src/timing.ts CHANGED
@@ -124,10 +124,14 @@ export function timing(options: TimingOptions = {}): ContextPlugin<{ timing: Tim
124
124
  if (metrics.length === 0) return res
125
125
 
126
126
  const value = metrics.map((metric) => formatMetric(metric, precision)).join(", ")
127
- return withHeaders(res, (headers) => {
128
- const existing = headers.get("server-timing")
129
- headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
130
- })
127
+ return withHeaders(
128
+ res,
129
+ (headers) => {
130
+ const existing = headers.get("server-timing")
131
+ headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
132
+ },
133
+ req,
134
+ )
131
135
  }),
132
136
  )
133
137
  }
package/src/token-auth.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
- import { bearerToken, guardName } from "./_utils.ts"
3
+ import { bearerToken, guardName, type HeaderGet, installAuthGuard } from "./_utils.ts"
4
4
 
5
5
  type MaybePromise<T> = T | Promise<T>
6
6
 
@@ -29,8 +29,8 @@ export type AuthPlugin<P> = IdentityPlugin & {
29
29
 
30
30
  interface TokenAuthConfig<P> {
31
31
  readonly name: string
32
- /** Pull the credential out of the request (e.g. the `Bearer` token or an `x-api-key` header). */
33
- readonly extract: (request: Request) => string | undefined
32
+ /** Pull the credential out of the request's headers (the `Bearer` token, an `x-api-key`). */
33
+ readonly extract: (header: HeaderGet) => string | undefined
34
34
  /** Verify the credential → a principal (truthy) or `null`/`undefined` (rejected). */
35
35
  readonly verify: (token: string) => MaybePromise<P | null | undefined>
36
36
  readonly optional: boolean
@@ -39,7 +39,7 @@ interface TokenAuthConfig<P> {
39
39
  }
40
40
 
41
41
  function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
42
- // Per-request principal cache: verify runs once (in beforeHandle), reads are free + can't desync.
42
+ // Per-request principal cache: verify runs once, in the guard; reads are free and can't desync.
43
43
  const store = new WeakMap<Request, P>()
44
44
  const reject = (): Response =>
45
45
  Response.json(
@@ -50,16 +50,18 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
50
50
  },
51
51
  )
52
52
  const plugin = defineIdentityPlugin(guardName(config.name), (app) =>
53
- app.beforeHandle(async (c: { readonly req: Request }) => {
54
- const token = config.extract(c.req)
55
- // Empty string is treated as "no credential" - never passed to verify.
56
- const principal = token !== undefined && token !== "" ? await config.verify(token) : null
57
- if (principal !== null && principal !== undefined) {
58
- store.set(c.req, principal)
59
- return undefined // authorized - continue to the handler
60
- }
61
- return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
62
- }),
53
+ installAuthGuard(
54
+ app,
55
+ config.name,
56
+ config.optional,
57
+ store,
58
+ (header) => {
59
+ const token = config.extract(header)
60
+ // Empty string is treated as "no credential" - never passed to verify.
61
+ return token !== undefined && token !== "" ? config.verify(token) : null
62
+ },
63
+ reject,
64
+ ),
63
65
  )
64
66
  const instrumented = config.optional
65
67
  ? plugin
@@ -97,7 +99,7 @@ export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
97
99
  const realm = options.realm ?? "api"
98
100
  return createTokenAuth({
99
101
  name: "bearer",
100
- extract: (request) => bearerToken(request.headers.get("authorization")) ?? undefined,
102
+ extract: (header) => bearerToken(header("authorization")) ?? undefined,
101
103
  verify: options.verify,
102
104
  optional: options.optional === true,
103
105
  challenge: `Bearer realm="${realm}"`,
@@ -171,7 +173,7 @@ export function apiKey<P>(
171
173
  const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify
172
174
  return createTokenAuth<P | string>({
173
175
  name: "apiKey",
174
- extract: (request) => request.headers.get(header) ?? undefined,
176
+ extract: (get) => get(header) ?? undefined,
175
177
  verify,
176
178
  optional: options.optional === true,
177
179
  })