@nifrajs/middleware 0.1.0-alpha.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/_utils.d.ts +27 -0
  4. package/dist/_utils.d.ts.map +1 -0
  5. package/dist/_utils.js +141 -0
  6. package/dist/_utils.js.map +1 -0
  7. package/dist/basic-auth.d.ts +27 -0
  8. package/dist/basic-auth.d.ts.map +1 -0
  9. package/dist/basic-auth.js +64 -0
  10. package/dist/basic-auth.js.map +1 -0
  11. package/dist/body-limit.d.ts +19 -0
  12. package/dist/body-limit.d.ts.map +1 -0
  13. package/dist/body-limit.js +42 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/cache-control.d.ts +23 -0
  16. package/dist/cache-control.d.ts.map +1 -0
  17. package/dist/cache-control.js +35 -0
  18. package/dist/cache-control.js.map +1 -0
  19. package/dist/cache.d.ts +60 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +224 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/combine.d.ts +7 -0
  24. package/dist/combine.d.ts.map +1 -0
  25. package/dist/combine.js +21 -0
  26. package/dist/combine.js.map +1 -0
  27. package/dist/compression.d.ts +29 -0
  28. package/dist/compression.d.ts.map +1 -0
  29. package/dist/compression.js +105 -0
  30. package/dist/compression.js.map +1 -0
  31. package/dist/context-storage.d.ts +14 -0
  32. package/dist/context-storage.d.ts.map +1 -0
  33. package/dist/context-storage.js +30 -0
  34. package/dist/context-storage.js.map +1 -0
  35. package/dist/cors.d.ts +25 -0
  36. package/dist/cors.d.ts.map +1 -0
  37. package/dist/cors.js +75 -0
  38. package/dist/cors.js.map +1 -0
  39. package/dist/csrf.d.ts +23 -0
  40. package/dist/csrf.d.ts.map +1 -0
  41. package/dist/csrf.js +68 -0
  42. package/dist/csrf.js.map +1 -0
  43. package/dist/etag.d.ts +14 -0
  44. package/dist/etag.d.ts.map +1 -0
  45. package/dist/etag.js +95 -0
  46. package/dist/etag.js.map +1 -0
  47. package/dist/healthcheck.d.ts +25 -0
  48. package/dist/healthcheck.d.ts.map +1 -0
  49. package/dist/healthcheck.js +41 -0
  50. package/dist/healthcheck.js.map +1 -0
  51. package/dist/idempotency.d.ts +83 -0
  52. package/dist/idempotency.d.ts.map +1 -0
  53. package/dist/idempotency.js +217 -0
  54. package/dist/idempotency.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ip-restriction.d.ts +21 -0
  60. package/dist/ip-restriction.d.ts.map +1 -0
  61. package/dist/ip-restriction.js +161 -0
  62. package/dist/ip-restriction.js.map +1 -0
  63. package/dist/jwt.d.ts +82 -0
  64. package/dist/jwt.d.ts.map +1 -0
  65. package/dist/jwt.js +327 -0
  66. package/dist/jwt.js.map +1 -0
  67. package/dist/language.d.ts +20 -0
  68. package/dist/language.d.ts.map +1 -0
  69. package/dist/language.js +94 -0
  70. package/dist/language.js.map +1 -0
  71. package/dist/logger.d.ts +19 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +28 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/method-override.d.ts +28 -0
  76. package/dist/method-override.d.ts.map +1 -0
  77. package/dist/method-override.js +67 -0
  78. package/dist/method-override.js.map +1 -0
  79. package/dist/openapi.d.ts +83 -0
  80. package/dist/openapi.d.ts.map +1 -0
  81. package/dist/openapi.js +100 -0
  82. package/dist/openapi.js.map +1 -0
  83. package/dist/powered-by.d.ts +15 -0
  84. package/dist/powered-by.d.ts.map +1 -0
  85. package/dist/powered-by.js +23 -0
  86. package/dist/powered-by.js.map +1 -0
  87. package/dist/pretty-json.d.ts +18 -0
  88. package/dist/pretty-json.d.ts.map +1 -0
  89. package/dist/pretty-json.js +88 -0
  90. package/dist/pretty-json.js.map +1 -0
  91. package/dist/rate-limit.d.ts +92 -0
  92. package/dist/rate-limit.d.ts.map +1 -0
  93. package/dist/rate-limit.js +172 -0
  94. package/dist/rate-limit.js.map +1 -0
  95. package/dist/request-id.d.ts +18 -0
  96. package/dist/request-id.d.ts.map +1 -0
  97. package/dist/request-id.js +21 -0
  98. package/dist/request-id.js.map +1 -0
  99. package/dist/security-headers.d.ts +23 -0
  100. package/dist/security-headers.d.ts.map +1 -0
  101. package/dist/security-headers.js +36 -0
  102. package/dist/security-headers.js.map +1 -0
  103. package/dist/timing.d.ts +25 -0
  104. package/dist/timing.d.ts.map +1 -0
  105. package/dist/timing.js +101 -0
  106. package/dist/timing.js.map +1 -0
  107. package/dist/token-auth.d.ts +65 -0
  108. package/dist/token-auth.d.ts.map +1 -0
  109. package/dist/token-auth.js +90 -0
  110. package/dist/token-auth.js.map +1 -0
  111. package/dist/trailing-slash.d.ts +23 -0
  112. package/dist/trailing-slash.d.ts.map +1 -0
  113. package/dist/trailing-slash.js +70 -0
  114. package/dist/trailing-slash.js.map +1 -0
  115. package/package.json +68 -0
  116. package/src/_utils.ts +163 -0
  117. package/src/basic-auth.ts +109 -0
  118. package/src/body-limit.ts +53 -0
  119. package/src/cache-control.ts +45 -0
  120. package/src/cache.ts +277 -0
  121. package/src/combine.ts +24 -0
  122. package/src/compression.ts +111 -0
  123. package/src/context-storage.ts +36 -0
  124. package/src/cors.ts +90 -0
  125. package/src/csrf.ts +102 -0
  126. package/src/etag.ts +99 -0
  127. package/src/healthcheck.ts +59 -0
  128. package/src/idempotency.ts +313 -0
  129. package/src/index.ts +110 -0
  130. package/src/ip-restriction.ts +187 -0
  131. package/src/jwt.ts +441 -0
  132. package/src/language.ts +109 -0
  133. package/src/logger.ts +44 -0
  134. package/src/method-override.ts +87 -0
  135. package/src/openapi.ts +174 -0
  136. package/src/powered-by.ts +31 -0
  137. package/src/pretty-json.ts +97 -0
  138. package/src/rate-limit.ts +248 -0
  139. package/src/request-id.ts +30 -0
  140. package/src/security-headers.ts +51 -0
  141. package/src/timing.ts +133 -0
  142. package/src/token-auth.ts +172 -0
  143. package/src/trailing-slash.ts +94 -0
@@ -0,0 +1,187 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import { jsonError, type MaybePromise } from "./_utils.ts"
3
+
4
+ export type IpMatcher = string | ((ip: string, request: Request) => MaybePromise<boolean>)
5
+
6
+ export interface IpRestrictionOptions {
7
+ readonly allow?: readonly IpMatcher[]
8
+ readonly deny?: readonly IpMatcher[]
9
+ /** Preferred extraction hook when the adapter/app knows the peer address. */
10
+ readonly clientIp?: (request: Request) => MaybePromise<string | null | undefined>
11
+ /** Trusted proxy count for `X-Forwarded-For` extraction. Default: 0, so XFF is ignored. */
12
+ readonly trustedProxies?: number
13
+ /** Exact trusted single-IP header, e.g. an infra-set `x-real-ip`. Not used unless configured. */
14
+ readonly header?: string
15
+ readonly error?: string
16
+ }
17
+
18
+ interface ParsedIp {
19
+ readonly version: 4 | 6
20
+ readonly value: bigint
21
+ }
22
+
23
+ interface Range {
24
+ readonly version: 4 | 6
25
+ readonly network: bigint
26
+ readonly mask: bigint
27
+ }
28
+
29
+ function parseIPv4(input: string): bigint | null {
30
+ const parts = input.split(".")
31
+ if (parts.length !== 4) return null
32
+ let out = 0n
33
+ for (const part of parts) {
34
+ if (!/^\d{1,3}$/.test(part)) return null
35
+ const n = Number(part)
36
+ if (n > 255) return null
37
+ out = (out << 8n) | BigInt(n)
38
+ }
39
+ return out
40
+ }
41
+
42
+ function parseIPv6(input: string): bigint | null {
43
+ if (input.includes("%")) return null
44
+ let source = input.toLowerCase()
45
+ if (source.includes(".")) {
46
+ const lastColon = source.lastIndexOf(":")
47
+ if (lastColon < 0) return null
48
+ const ipv4 = parseIPv4(source.slice(lastColon + 1))
49
+ if (ipv4 === null) return null
50
+ const hi = Number((ipv4 >> 16n) & 0xffffn).toString(16)
51
+ const lo = Number(ipv4 & 0xffffn).toString(16)
52
+ source = `${source.slice(0, lastColon)}:${hi}:${lo}`
53
+ }
54
+
55
+ const halves = source.split("::")
56
+ if (halves.length > 2) return null
57
+ const left = halves[0] === "" ? [] : halves[0]!.split(":")
58
+ const right = halves.length === 1 || halves[1] === "" ? [] : halves[1]!.split(":")
59
+ const groups = [...left, ...right]
60
+ if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null
61
+
62
+ const missing = 8 - groups.length
63
+ if (halves.length === 1) {
64
+ if (missing !== 0) return null
65
+ } else if (missing < 1) return null
66
+
67
+ const expanded =
68
+ halves.length === 1
69
+ ? groups
70
+ : [...left, ...Array.from({ length: missing }, () => "0"), ...right]
71
+ let out = 0n
72
+ for (const group of expanded) out = (out << 16n) | BigInt(Number.parseInt(group, 16))
73
+ return out
74
+ }
75
+
76
+ function parseIp(input: string): ParsedIp | null {
77
+ const trimmed = input.trim()
78
+ const unbracketed =
79
+ trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed
80
+ const v4 = parseIPv4(unbracketed)
81
+ if (v4 !== null) return { version: 4, value: v4 }
82
+ const v6 = parseIPv6(unbracketed)
83
+ return v6 === null ? null : { version: 6, value: v6 }
84
+ }
85
+
86
+ function parseRange(input: string): Range {
87
+ const slash = input.indexOf("/")
88
+ const address = slash < 0 ? input : input.slice(0, slash)
89
+ const ip = parseIp(address)
90
+ if (ip === null) throw new Error(`ipRestriction: invalid IP/CIDR ${JSON.stringify(input)}`)
91
+ const bits = ip.version === 4 ? 32 : 128
92
+ const prefix =
93
+ slash < 0 ? bits : /^\d+$/.test(input.slice(slash + 1)) ? Number(input.slice(slash + 1)) : -1
94
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) {
95
+ throw new Error(`ipRestriction: invalid CIDR prefix ${JSON.stringify(input)}`)
96
+ }
97
+ const full = (1n << BigInt(bits)) - 1n
98
+ const mask = prefix === 0 ? 0n : full ^ ((1n << BigInt(bits - prefix)) - 1n)
99
+ return { version: ip.version, network: ip.value & mask, mask }
100
+ }
101
+
102
+ function matchRange(ip: ParsedIp, range: Range): boolean {
103
+ return ip.version === range.version && (ip.value & range.mask) === range.network
104
+ }
105
+
106
+ function xForwardedClient(req: Request, trustedProxies: number): string | null {
107
+ if (trustedProxies <= 0) return null
108
+ const xff = req.headers.get("x-forwarded-for")
109
+ if (xff === null) return null
110
+ const parts = xff.split(",")
111
+ return parts[parts.length - trustedProxies]?.trim() || null
112
+ }
113
+
114
+ async function resolveClientIp(
115
+ req: Request,
116
+ options: IpRestrictionOptions,
117
+ ): Promise<string | null> {
118
+ const custom = await options.clientIp?.(req)
119
+ if (custom !== undefined && custom !== null) return custom
120
+ const fromXff = xForwardedClient(req, options.trustedProxies ?? 0)
121
+ if (fromXff !== null) return fromXff
122
+ if (options.header !== undefined) {
123
+ const value = req.headers.get(options.header)
124
+ if (value !== null && !value.includes(",")) return value.trim()
125
+ }
126
+ return null
127
+ }
128
+
129
+ function compile(matchers: readonly IpMatcher[] | undefined): {
130
+ readonly ranges: readonly Range[]
131
+ readonly fns: readonly ((ip: string, req: Request) => MaybePromise<boolean>)[]
132
+ } {
133
+ const ranges: Range[] = []
134
+ const fns: ((ip: string, req: Request) => MaybePromise<boolean>)[] = []
135
+ for (const matcher of matchers ?? []) {
136
+ if (typeof matcher === "string") ranges.push(parseRange(matcher))
137
+ else fns.push(matcher)
138
+ }
139
+ return { ranges, fns }
140
+ }
141
+
142
+ async function matches(
143
+ ipText: string,
144
+ ip: ParsedIp,
145
+ compiled: ReturnType<typeof compile>,
146
+ req: Request,
147
+ ): Promise<boolean> {
148
+ if (compiled.ranges.some((range) => matchRange(ip, range))) return true
149
+ for (const fn of compiled.fns) if (await fn(ipText, req)) return true
150
+ return false
151
+ }
152
+
153
+ /**
154
+ * IP allow/deny middleware. It fails closed when no trustworthy client IP can be derived. Configure
155
+ * `clientIp`, `trustedProxies`, or a trusted single-IP `header`; unconfigured X-Forwarded-For is never
156
+ * trusted.
157
+ */
158
+ export function ipRestriction(options: IpRestrictionOptions): Middleware {
159
+ const trustedProxies = options.trustedProxies ?? 0
160
+ if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
161
+ throw new Error("ipRestriction: trustedProxies must be a non-negative integer")
162
+ }
163
+ if (options.header !== undefined && options.header.trim() === "") {
164
+ throw new Error("ipRestriction: header must not be empty")
165
+ }
166
+ const allow = compile(options.allow)
167
+ const deny = compile(options.deny)
168
+ if (allow.ranges.length + allow.fns.length + deny.ranges.length + deny.fns.length === 0) {
169
+ throw new Error("ipRestriction: configure at least one allow or deny matcher")
170
+ }
171
+ const error = options.error ?? "ip_forbidden"
172
+
173
+ return {
174
+ name: "ip-restriction",
175
+ async onRequest(req) {
176
+ const ipText = await resolveClientIp(req, options)
177
+ if (ipText === null) return jsonError(403, error)
178
+ const ip = parseIp(ipText)
179
+ if (ip === null) return jsonError(403, error)
180
+ if (await matches(ipText, ip, deny, req)) return jsonError(403, error)
181
+ if (allow.ranges.length + allow.fns.length > 0 && !(await matches(ipText, ip, allow, req))) {
182
+ return jsonError(403, error)
183
+ }
184
+ return undefined
185
+ },
186
+ }
187
+ }
package/src/jwt.ts ADDED
@@ -0,0 +1,441 @@
1
+ import { definePlugin, type NifraPlugin } from "@nifrajs/core"
2
+ import {
3
+ base64UrlDecode,
4
+ jsonError,
5
+ type MaybePromise,
6
+ parseCookies,
7
+ quotedHeaderValue,
8
+ secretBytes,
9
+ utf8Bytes,
10
+ } from "./_utils.ts"
11
+
12
+ export type JwtAlgorithm = "HS256" | "HS384" | "HS512" | "RS256" | "RS384" | "RS512"
13
+
14
+ export interface JwtHeader {
15
+ readonly alg: string
16
+ readonly kid?: string
17
+ readonly typ?: string
18
+ readonly [key: string]: unknown
19
+ }
20
+
21
+ export interface JwtClaims {
22
+ readonly iss?: string
23
+ readonly sub?: string
24
+ readonly aud?: string | readonly string[]
25
+ readonly exp?: number
26
+ readonly nbf?: number
27
+ readonly iat?: number
28
+ readonly jti?: string
29
+ readonly [claim: string]: unknown
30
+ }
31
+
32
+ export interface VerifiedJwt<C extends JwtClaims = JwtClaims> {
33
+ readonly header: JwtHeader
34
+ readonly claims: C
35
+ readonly token: string
36
+ }
37
+
38
+ export type VerifyJwtResult<C extends JwtClaims = JwtClaims> =
39
+ | { readonly ok: true; readonly data: VerifiedJwt<C> }
40
+ | { readonly ok: false; readonly error: Error }
41
+
42
+ export interface JwkKey {
43
+ readonly kty?: string
44
+ readonly kid?: string
45
+ readonly alg?: string
46
+ readonly use?: string
47
+ readonly key_ops?: readonly string[]
48
+ readonly [key: string]: unknown
49
+ }
50
+
51
+ export type JwtVerificationKey = string | Uint8Array | CryptoKey | JwkKey
52
+ export type JwtKeyResolver = (
53
+ header: JwtHeader,
54
+ claims: JwtClaims,
55
+ ) => MaybePromise<JwtVerificationKey | null | undefined>
56
+
57
+ export interface VerifyJwtOptions {
58
+ readonly key: JwtVerificationKey | JwtKeyResolver
59
+ readonly algorithms: readonly JwtAlgorithm[]
60
+ readonly issuer?: string | readonly string[]
61
+ readonly audience?: string | readonly string[]
62
+ readonly clockToleranceSec?: number
63
+ readonly requiredClaims?: readonly string[]
64
+ readonly requireExpiration?: boolean
65
+ readonly maxAgeSec?: number
66
+ readonly now?: () => number
67
+ }
68
+
69
+ export interface JwtOptions extends VerifyJwtOptions {
70
+ readonly realm?: string
71
+ readonly optional?: boolean
72
+ /** Header carrying the token. Default `"authorization"` (`Bearer <token>`). */
73
+ readonly header?: string
74
+ /** Optional cookie fallback carrying the raw token. */
75
+ readonly cookie?: string
76
+ }
77
+
78
+ export type JwtPlugin<C extends JwtClaims = JwtClaims> = NifraPlugin & {
79
+ claims(request: Request): C | null
80
+ requireClaims(request: Request): C
81
+ }
82
+
83
+ export interface JwksOptions {
84
+ readonly url: string | URL
85
+ readonly fetch?: typeof fetch
86
+ readonly cacheMs?: number
87
+ /**
88
+ * Extra time to keep using the last successful key set when refresh fails.
89
+ * Defaults to one cache window; set `0` to fail closed immediately on refresh errors.
90
+ */
91
+ readonly staleMs?: number
92
+ readonly timeoutMs?: number
93
+ readonly maxBytes?: number
94
+ }
95
+
96
+ const HASH: Record<JwtAlgorithm, "SHA-256" | "SHA-384" | "SHA-512"> = {
97
+ HS256: "SHA-256",
98
+ HS384: "SHA-384",
99
+ HS512: "SHA-512",
100
+ RS256: "SHA-256",
101
+ RS384: "SHA-384",
102
+ RS512: "SHA-512",
103
+ }
104
+
105
+ const HMAC_ALGS = new Set<JwtAlgorithm>(["HS256", "HS384", "HS512"])
106
+ const RSA_ALGS = new Set<JwtAlgorithm>(["RS256", "RS384", "RS512"])
107
+ const JSON_DECODER = new TextDecoder("utf-8", { fatal: true })
108
+
109
+ function isObject(v: unknown): v is Record<string, unknown> {
110
+ return typeof v === "object" && v !== null && !Array.isArray(v)
111
+ }
112
+
113
+ function jwtError(message: string): Error {
114
+ return new Error(`jwt: ${message}`)
115
+ }
116
+
117
+ function decodeJsonSegment(segment: string): unknown {
118
+ const bytes = base64UrlDecode(segment)
119
+ if (bytes === null) throw jwtError("invalid base64url segment")
120
+ try {
121
+ return JSON.parse(JSON_DECODER.decode(bytes))
122
+ } catch {
123
+ throw jwtError("invalid JSON segment")
124
+ }
125
+ }
126
+
127
+ function validateAlgorithms(algorithms: readonly JwtAlgorithm[]): Set<JwtAlgorithm> {
128
+ if (algorithms.length === 0) throw new Error("jwt: algorithms must not be empty")
129
+ return new Set(algorithms)
130
+ }
131
+
132
+ function nonNegativeSeconds(value: number | undefined, name: string): number {
133
+ if (value === undefined) return 0
134
+ if (!Number.isFinite(value) || value < 0) throw jwtError(`${name} must be non-negative`)
135
+ return value
136
+ }
137
+
138
+ function validateJwkForAlg(key: JwkKey, alg: JwtAlgorithm, kid: string | undefined): boolean {
139
+ if (key.alg !== undefined && key.alg !== alg) return false
140
+ if (key.use !== undefined && key.use !== "sig") return false
141
+ if (key.key_ops !== undefined && !key.key_ops.includes("verify")) return false
142
+ if (kid !== undefined && key.kid !== undefined && key.kid !== kid) return false
143
+ if (HMAC_ALGS.has(alg)) return key.kty === "oct"
144
+ if (RSA_ALGS.has(alg)) return key.kty === "RSA"
145
+ return false
146
+ }
147
+
148
+ async function importKey(
149
+ key: JwtVerificationKey,
150
+ alg: JwtAlgorithm,
151
+ kid: string | undefined,
152
+ ): Promise<CryptoKey> {
153
+ const hash = HASH[alg]
154
+ if (key instanceof CryptoKey) return key
155
+ if (typeof key === "string" || key instanceof Uint8Array) {
156
+ if (!HMAC_ALGS.has(alg)) throw jwtError("symmetric key cannot verify an RSA token")
157
+ return crypto.subtle.importKey("raw", secretBytes(key, "jwt"), { name: "HMAC", hash }, false, [
158
+ "verify",
159
+ ])
160
+ }
161
+ if (!validateJwkForAlg(key, alg, kid)) throw jwtError("JWK is not usable for this token")
162
+ const importJwk = crypto.subtle.importKey as unknown as (
163
+ format: "jwk",
164
+ keyData: JwkKey,
165
+ algorithm: unknown,
166
+ extractable: boolean,
167
+ keyUsages: string[],
168
+ ) => Promise<CryptoKey>
169
+ return importJwk.call(
170
+ crypto.subtle,
171
+ "jwk",
172
+ key,
173
+ RSA_ALGS.has(alg) ? { name: "RSASSA-PKCS1-v1_5", hash } : { name: "HMAC", hash },
174
+ false,
175
+ ["verify"],
176
+ )
177
+ }
178
+
179
+ function verifyAlgorithm(alg: string, allowed: Set<JwtAlgorithm>): JwtAlgorithm {
180
+ if (alg === "none") throw jwtError("alg none is not allowed")
181
+ if (!allowed.has(alg as JwtAlgorithm)) throw jwtError("algorithm is not allowed")
182
+ return alg as JwtAlgorithm
183
+ }
184
+
185
+ function includesString(expected: string | readonly string[] | undefined, actual: string): boolean {
186
+ return expected === undefined
187
+ ? true
188
+ : typeof expected === "string"
189
+ ? expected === actual
190
+ : expected.includes(actual)
191
+ }
192
+
193
+ function validateClaims(claims: JwtClaims, options: VerifyJwtOptions): void {
194
+ const now = options.now?.() ?? Math.floor(Date.now() / 1000)
195
+ if (!Number.isFinite(now)) throw jwtError("now must be finite")
196
+ const tolerance = nonNegativeSeconds(options.clockToleranceSec, "clockToleranceSec")
197
+ const requireExpiration = options.requireExpiration !== false
198
+
199
+ if (requireExpiration && typeof claims.exp !== "number") throw jwtError("exp is required")
200
+ if (claims.exp !== undefined && (!Number.isFinite(claims.exp) || claims.exp <= now - tolerance)) {
201
+ throw jwtError("token expired")
202
+ }
203
+ if (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || claims.nbf > now + tolerance)) {
204
+ throw jwtError("token is not active yet")
205
+ }
206
+ if (claims.iat !== undefined && (!Number.isFinite(claims.iat) || claims.iat > now + tolerance)) {
207
+ throw jwtError("iat is in the future")
208
+ }
209
+ if (options.maxAgeSec !== undefined) {
210
+ nonNegativeSeconds(options.maxAgeSec, "maxAgeSec")
211
+ if (typeof claims.iat !== "number" || !Number.isFinite(claims.iat))
212
+ throw jwtError("iat is required")
213
+ if (now - claims.iat > options.maxAgeSec + tolerance) throw jwtError("token is too old")
214
+ }
215
+ if (options.issuer !== undefined) {
216
+ if (typeof claims.iss !== "string" || !includesString(options.issuer, claims.iss)) {
217
+ throw jwtError("issuer mismatch")
218
+ }
219
+ }
220
+ if (options.audience !== undefined) {
221
+ const got = claims.aud
222
+ const gotList = typeof got === "string" ? [got] : Array.isArray(got) ? got : []
223
+ const expected = typeof options.audience === "string" ? [options.audience] : options.audience
224
+ if (!gotList.some((aud) => expected.includes(aud))) throw jwtError("audience mismatch")
225
+ }
226
+ for (const claim of options.requiredClaims ?? []) {
227
+ if (!(claim in claims)) throw jwtError(`claim ${claim} is required`)
228
+ }
229
+ }
230
+
231
+ export async function verifyJwt<C extends JwtClaims = JwtClaims>(
232
+ token: string,
233
+ options: VerifyJwtOptions,
234
+ ): Promise<VerifiedJwt<C>> {
235
+ const allowed = validateAlgorithms(options.algorithms)
236
+ const parts = token.split(".")
237
+ if (parts.length !== 3 || parts.some((p) => p === "")) throw jwtError("expected three segments")
238
+
239
+ const headerRaw = decodeJsonSegment(parts[0]!)
240
+ const claimsRaw = decodeJsonSegment(parts[1]!)
241
+ if (!isObject(headerRaw) || typeof headerRaw.alg !== "string") throw jwtError("invalid header")
242
+ if (headerRaw.crit !== undefined) throw jwtError("crit headers are not supported")
243
+ if (!isObject(claimsRaw)) throw jwtError("invalid claims")
244
+
245
+ const header = headerRaw as unknown as JwtHeader
246
+ const claims = claimsRaw as unknown as C
247
+ const alg = verifyAlgorithm(header.alg, allowed)
248
+
249
+ const resolved =
250
+ typeof options.key === "function" ? await options.key(header, claims) : options.key
251
+ if (resolved === null || resolved === undefined) throw jwtError("key not found")
252
+
253
+ const signature = base64UrlDecode(parts[2]!)
254
+ if (signature === null) throw jwtError("invalid signature encoding")
255
+ const key = await importKey(resolved, alg, header.kid)
256
+ const ok = await crypto.subtle.verify(
257
+ HMAC_ALGS.has(alg) ? { name: "HMAC" } : { name: "RSASSA-PKCS1-v1_5" },
258
+ key,
259
+ signature,
260
+ utf8Bytes(`${parts[0]}.${parts[1]}`),
261
+ )
262
+ if (!ok) throw jwtError("signature mismatch")
263
+ validateClaims(claims, options)
264
+ return { header, claims, token }
265
+ }
266
+
267
+ function toError(error: unknown): Error {
268
+ return error instanceof Error ? error : jwtError("verification failed")
269
+ }
270
+
271
+ export async function tryVerifyJwt<C extends JwtClaims = JwtClaims>(
272
+ token: string,
273
+ options: VerifyJwtOptions,
274
+ ): Promise<VerifyJwtResult<C>> {
275
+ try {
276
+ return { ok: true, data: await verifyJwt<C>(token, options) }
277
+ } catch (error) {
278
+ return { ok: false, error: toError(error) }
279
+ }
280
+ }
281
+
282
+ function tokenFromRequest(req: Request, header: string, cookie: string | undefined): string | null {
283
+ const headerValue = req.headers.get(header)
284
+ if (headerValue !== null) {
285
+ if (header === "authorization") {
286
+ return headerValue.startsWith("Bearer ") ? headerValue.slice(7).trim() || null : null
287
+ }
288
+ return headerValue.trim() || null
289
+ }
290
+ return cookie === undefined ? null : (parseCookies(req.headers.get("cookie"))[cookie] ?? null)
291
+ }
292
+
293
+ function challenge(realm: string): string {
294
+ return `Bearer realm="${quotedHeaderValue(realm)}"`
295
+ }
296
+
297
+ function reject(realm: string): Response {
298
+ return jsonError(401, "unauthorized", { "www-authenticate": challenge(realm) })
299
+ }
300
+
301
+ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPlugin<C> {
302
+ validateAlgorithms(options.algorithms)
303
+ const realm = options.realm ?? "api"
304
+ const optional = options.optional === true
305
+ const header = (options.header ?? "authorization").toLowerCase()
306
+ const store = new WeakMap<Request, C>()
307
+
308
+ const plugin = definePlugin("jwt", (app) =>
309
+ app.beforeHandle(async (c: { readonly req: Request }) => {
310
+ const token = tokenFromRequest(c.req, header, options.cookie)
311
+ if (token === null) return optional ? undefined : reject(realm)
312
+ try {
313
+ const verified = await verifyJwt<C>(token, options)
314
+ store.set(c.req, verified.claims)
315
+ return undefined
316
+ } catch {
317
+ return optional ? undefined : reject(realm)
318
+ }
319
+ }),
320
+ )
321
+ return Object.assign(plugin, {
322
+ claims: (request: Request): C | null => store.get(request) ?? null,
323
+ requireClaims: (request: Request): C => {
324
+ const claims = store.get(request)
325
+ if (claims === undefined) throw reject(realm)
326
+ return claims
327
+ },
328
+ }) as JwtPlugin<C>
329
+ }
330
+
331
+ export function jwk(key: JwtVerificationKey): JwtKeyResolver {
332
+ return (header) => {
333
+ if (typeof key === "object" && !(key instanceof Uint8Array) && !(key instanceof CryptoKey)) {
334
+ if (header.kid !== undefined && key.kid !== undefined && key.kid !== header.kid) return null
335
+ }
336
+ return key
337
+ }
338
+ }
339
+
340
+ function validateJwksUrl(url: URL): void {
341
+ const local =
342
+ url.hostname === "localhost" ||
343
+ url.hostname === "127.0.0.1" ||
344
+ url.hostname === "::1" ||
345
+ url.hostname === "[::1]"
346
+ if (url.protocol !== "https:" && !local) {
347
+ throw new Error("jwks: url must be https unless it targets localhost")
348
+ }
349
+ }
350
+
351
+ async function readTextBounded(res: Response, maxBytes: number): Promise<string> {
352
+ const declared = res.headers.get("content-length")
353
+ if (declared !== null && /^(?:0|[1-9]\d*)$/.test(declared) && Number(declared) > maxBytes) {
354
+ throw new Error("jwks: response too large")
355
+ }
356
+ const body = res.body
357
+ if (body === null) return ""
358
+ const reader = body.getReader()
359
+ const decoder = new TextDecoder()
360
+ let total = 0
361
+ let text = ""
362
+ try {
363
+ for (;;) {
364
+ const { done, value } = await reader.read()
365
+ if (done) return text + decoder.decode()
366
+ total += value.byteLength
367
+ if (total > maxBytes) {
368
+ await reader.cancel()
369
+ throw new Error("jwks: response too large")
370
+ }
371
+ text += decoder.decode(value, { stream: true })
372
+ }
373
+ } catch (err) {
374
+ if (err instanceof Error && err.message === "jwks: response too large") throw err
375
+ throw new Error("jwks: response read failed")
376
+ }
377
+ }
378
+
379
+ async function fetchJwks(
380
+ options: Required<Pick<JwksOptions, "fetch" | "timeoutMs" | "maxBytes">> & { url: URL },
381
+ ): Promise<readonly JwkKey[]> {
382
+ const ctrl = new AbortController()
383
+ const timer = setTimeout(() => ctrl.abort(), options.timeoutMs)
384
+ try {
385
+ const res = await options.fetch(options.url, { signal: ctrl.signal })
386
+ if (!res.ok) throw new Error(`jwks: fetch failed with ${res.status}`)
387
+ const text = await readTextBounded(res, options.maxBytes)
388
+ const parsed: unknown = JSON.parse(text)
389
+ if (!isObject(parsed) || !Array.isArray(parsed.keys)) throw new Error("jwks: invalid set")
390
+ return parsed.keys.filter(isObject) as JwkKey[]
391
+ } finally {
392
+ clearTimeout(timer)
393
+ }
394
+ }
395
+
396
+ export function jwks(options: JwksOptions): JwtKeyResolver {
397
+ const url = new URL(String(options.url))
398
+ validateJwksUrl(url)
399
+ const fetcher = options.fetch ?? fetch
400
+ const cacheMs = options.cacheMs ?? 300_000
401
+ const staleMs = options.staleMs ?? cacheMs
402
+ const timeoutMs = options.timeoutMs ?? 5_000
403
+ const maxBytes = options.maxBytes ?? 65_536
404
+ if (!Number.isFinite(cacheMs) || cacheMs < 0)
405
+ throw new Error("jwks: cacheMs must be non-negative")
406
+ if (!Number.isFinite(staleMs) || staleMs < 0)
407
+ throw new Error("jwks: staleMs must be non-negative")
408
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
409
+ throw new Error("jwks: timeoutMs must be positive")
410
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
411
+ throw new Error("jwks: maxBytes must be positive")
412
+ }
413
+ let cached: readonly JwkKey[] = []
414
+ let cachedAt = 0
415
+ let pending: Promise<readonly JwkKey[]> | undefined
416
+
417
+ const load = async (): Promise<readonly JwkKey[]> => {
418
+ const now = Date.now()
419
+ if (cached.length > 0 && now - cachedAt < cacheMs) return cached
420
+ pending ??= fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes }).finally(() => {
421
+ pending = undefined
422
+ })
423
+ try {
424
+ cached = await pending
425
+ cachedAt = Date.now()
426
+ return cached
427
+ } catch (error) {
428
+ if (cached.length > 0 && staleMs > 0 && Date.now() - cachedAt < cacheMs + staleMs) {
429
+ return cached
430
+ }
431
+ throw error
432
+ }
433
+ }
434
+
435
+ return async (header) => {
436
+ if (typeof header.kid !== "string" || header.kid === "") return null
437
+ const keys = await load()
438
+ const alg = header.alg as JwtAlgorithm
439
+ return keys.find((key) => key.kid === header.kid && validateJwkForAlg(key, alg, header.kid))
440
+ }
441
+ }