ingenium 0.0.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,143 @@
1
+ import { createPublicKey, type KeyObject } from 'node:crypto'
2
+
3
+ /**
4
+ * In-memory JWKS cache.
5
+ *
6
+ * One entry per URL. Each entry holds the parsed `Map<kid, KeyObject>` and
7
+ * the absolute timestamp at which it expires. A `pending` promise is stored
8
+ * alongside so concurrent callers for the same URL share a single in-flight
9
+ * fetch — we never stampede the upstream IdP.
10
+ */
11
+ interface CacheEntry {
12
+ keys: Map<string, KeyObject>
13
+ expiresAt: number
14
+ pending: Promise<Map<string, KeyObject>> | null
15
+ }
16
+
17
+ const cache = new Map<string, CacheEntry>()
18
+
19
+ /** JWK shape we accept. We tolerate extra fields and ignore unsupported `kty`. */
20
+ interface Jwk {
21
+ kty?: string
22
+ kid?: string
23
+ use?: string
24
+ alg?: string
25
+ n?: string
26
+ e?: string
27
+ crv?: string
28
+ x?: string
29
+ y?: string
30
+ // RSA private parts / EC `d` are intentionally ignored — verifier-only.
31
+ [k: string]: unknown
32
+ }
33
+
34
+ interface JwksResponse {
35
+ keys?: Jwk[]
36
+ }
37
+
38
+ /**
39
+ * Fetch + cache a JWKS. Returns a `Map<kid, KeyObject>`.
40
+ *
41
+ * Concurrency: if a fetch is already in flight for `url` we await the same
42
+ * promise, ensuring a thundering-herd of requests collapses to one upstream
43
+ * call. After the fetch resolves, all waiters get the same keys.
44
+ *
45
+ * Failure mode: any thrown error (network, JSON parse, malformed JWK, empty
46
+ * keyset) bubbles as a generic `Error('jwks_fetch_failed')` — the caller is
47
+ * responsible for translating to a wire-safe `IngeniumUnauthorizedError`. We
48
+ * deliberately do NOT serve a stale cache on failure: stale public keys can
49
+ * mean accepting tokens that the IdP has rotated away from.
50
+ */
51
+ export async function fetchJwks(url: string, ttlMs: number): Promise<Map<string, KeyObject>> {
52
+ const now = Date.now()
53
+ const entry = cache.get(url)
54
+
55
+ // Fresh cache hit — return synchronously-resolved map.
56
+ if (entry && entry.expiresAt > now && !entry.pending) {
57
+ return entry.keys
58
+ }
59
+
60
+ // In-flight coalescing: another caller already triggered the fetch.
61
+ if (entry?.pending) {
62
+ return entry.pending
63
+ }
64
+
65
+ const pending = doFetch(url).then(
66
+ (keys) => {
67
+ cache.set(url, { keys, expiresAt: Date.now() + ttlMs, pending: null })
68
+ return keys
69
+ },
70
+ (err) => {
71
+ // Drop the failed entry so the next caller retries (instead of being
72
+ // pinned to a rejected promise forever).
73
+ cache.delete(url)
74
+ throw err
75
+ },
76
+ )
77
+
78
+ // Park the in-flight promise so concurrent callers within this tick share it.
79
+ // Preserve the existing `keys` map so reads during refresh have something
80
+ // to fall back on if needed (currently unused — we always await `pending`).
81
+ cache.set(url, {
82
+ keys: entry?.keys ?? new Map(),
83
+ expiresAt: entry?.expiresAt ?? 0,
84
+ pending,
85
+ })
86
+
87
+ return pending
88
+ }
89
+
90
+ /** Reset the in-process cache. Tests use this; production code shouldn't need it. */
91
+ export function clearJwksCache(): void {
92
+ cache.clear()
93
+ }
94
+
95
+ async function doFetch(url: string): Promise<Map<string, KeyObject>> {
96
+ let res: Response
97
+ try {
98
+ res = await fetch(url)
99
+ } catch {
100
+ throw new Error('jwks_fetch_failed')
101
+ }
102
+ if (!res.ok) throw new Error('jwks_fetch_failed')
103
+
104
+ let body: unknown
105
+ try {
106
+ body = await res.json()
107
+ } catch {
108
+ throw new Error('jwks_fetch_failed')
109
+ }
110
+
111
+ if (!body || typeof body !== 'object') throw new Error('jwks_fetch_failed')
112
+ const jwks = body as JwksResponse
113
+ if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) {
114
+ throw new Error('jwks_fetch_failed')
115
+ }
116
+
117
+ const out = new Map<string, KeyObject>()
118
+ for (const jwk of jwks.keys) {
119
+ if (!jwk || typeof jwk !== 'object') continue
120
+ if (typeof jwk.kid !== 'string' || jwk.kid.length === 0) continue
121
+ if (jwk.kty !== 'RSA' && jwk.kty !== 'EC') continue
122
+ // EC: only accept the JWT-spec curves. P-521 (note: 521, not 512) is
123
+ // the curve name JOSE uses for ES512 — yes, the off-by-one is in the spec.
124
+ if (jwk.kty === 'EC' && jwk.crv !== 'P-256' && jwk.crv !== 'P-384' && jwk.crv !== 'P-521') {
125
+ continue
126
+ }
127
+ try {
128
+ // node:crypto accepts JWK directly when format is 'jwk'. For RSA it
129
+ // needs `n` + `e`; for EC it needs `crv` + `x` + `y`. Private fields
130
+ // are ignored when we createPublicKey.
131
+ const key = createPublicKey({ key: jwk as never, format: 'jwk' })
132
+ out.set(jwk.kid, key)
133
+ } catch {
134
+ // Skip individual bad keys rather than failing the whole keyset —
135
+ // an IdP rolling a new (broken) key shouldn't blow up verification of
136
+ // tokens signed with the still-valid old keys.
137
+ continue
138
+ }
139
+ }
140
+
141
+ if (out.size === 0) throw new Error('jwks_fetch_failed')
142
+ return out
143
+ }
@@ -0,0 +1,313 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import type { KeyObject } from 'node:crypto'
3
+ import { IngeniumUnauthorizedError } from '../errors.ts'
4
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
5
+ import type { IngeniumContext } from '../context/context.ts'
6
+ import type {
7
+ JwtAlgorithm,
8
+ JwtHeader,
9
+ JwtKey,
10
+ JwtOptions,
11
+ JwtSecret,
12
+ JwtSecretResolver,
13
+ JwtTokenReader,
14
+ JwtVerified,
15
+ } from './types.ts'
16
+ import { verifyJwt, type KidTaggedKey, type VerifyKeyMaterial } from './verify.ts'
17
+ import { fetchJwks } from './jwks.ts'
18
+
19
+ const DEFAULT_ALGORITHMS: readonly JwtAlgorithm[] = ['HS256']
20
+ const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000
21
+ const SUPPORTED: ReadonlySet<JwtAlgorithm> = new Set([
22
+ 'HS256', 'HS384', 'HS512',
23
+ 'RS256', 'RS384', 'RS512',
24
+ 'PS256', 'PS384', 'PS512',
25
+ 'ES256', 'ES384', 'ES512',
26
+ ])
27
+
28
+ /** Default token reader — `Authorization: Bearer <token>`. */
29
+ const defaultGetToken: JwtTokenReader = (ctx) => {
30
+ const raw = ctx.headers['authorization']
31
+ if (!raw) return undefined
32
+ const value = Array.isArray(raw) ? raw[0] : raw
33
+ if (!value) return undefined
34
+ // Bearer scheme is case-insensitive per RFC 6750 §2.1.
35
+ const space = value.indexOf(' ')
36
+ if (space < 0) return undefined
37
+ const scheme = value.slice(0, space)
38
+ if (scheme.toLowerCase() !== 'bearer') return undefined
39
+ const token = value.slice(space + 1).trim()
40
+ return token.length > 0 ? token : undefined
41
+ }
42
+
43
+ /**
44
+ * Bearer-token JWT verification middleware.
45
+ *
46
+ * Attaches the verified token at `ctx.jwt`. Callers should module-augment
47
+ * `IngeniumContext` for typed access (the framework purposely doesn't ship a
48
+ * baked-in `jwt` field — payload shape is application-specific).
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * declare module 'ingenium' {
53
+ * interface IngeniumContext {
54
+ * jwt?: import('ingenium').JwtVerified<{ sub: string; roles: string[] }>
55
+ * }
56
+ * }
57
+ *
58
+ * import { ingenium } from 'ingenium'
59
+ * const app = ingenium()
60
+ * // HMAC
61
+ * app.use(ingenium.jwt({ secret: process.env.JWT_SECRET! }))
62
+ * // RSA via JWKS (Auth0, Okta, Cognito, Clerk, Supabase, ...)
63
+ * app.use(ingenium.jwt({
64
+ * secret: [],
65
+ * algorithms: ['RS256'],
66
+ * jwksUrl: 'https://example.auth0.com/.well-known/jwks.json',
67
+ * issuer: 'https://example.auth0.com/',
68
+ * audience: 'https://api.example.com',
69
+ * }))
70
+ * ```
71
+ *
72
+ * Security choices:
73
+ * - Default leeway (`clockSkewSeconds`) is **5 seconds** — enough for typical
74
+ * multi-host clock drift, small enough that an expired token does not stay
75
+ * usable for long.
76
+ * - HMAC signature comparison uses `crypto.timingSafeEqual` after an explicit
77
+ * length check inside {@link verifyJwt}; asymmetric verification uses
78
+ * `crypto.verify` (constant-time within OpenSSL).
79
+ * - The algorithm allowlist is enforced at verify time. Even if an attacker
80
+ * crafts `alg: 'RS256'` and we have a matching JWKS key, verification fails
81
+ * unless `RS256` appears in `algorithms`. This is the canonical defence
82
+ * against algorithm-confusion attacks.
83
+ * - `'none'` is rejected unconditionally, regardless of the allowlist.
84
+ * - The wire-facing error is always `IngeniumUnauthorizedError('Invalid token')`
85
+ * regardless of which check failed (signature vs exp vs aud) — this avoids
86
+ * handing attackers an oracle. Detailed reasons go to `opts.logger` (or
87
+ * `process.emitWarning` if no logger is supplied).
88
+ */
89
+ export function jwtMiddleware<T = Record<string, unknown>>(
90
+ opts: JwtOptions<T>,
91
+ ): IngeniumMiddleware {
92
+ // ── Construction-time validation ─────────────────────────────────────────
93
+ if (opts == null) {
94
+ throw new Error('jwtMiddleware: options object is required')
95
+ }
96
+ // `secret` may be an empty array when the caller is leaning entirely on
97
+ // `jwksUrl` for key material — treat that as a valid configuration.
98
+ const hasJwks = typeof opts.jwksUrl === 'string' && opts.jwksUrl.length > 0
99
+ if ((opts.secret as unknown) === undefined || opts.secret === null) {
100
+ if (!hasJwks) {
101
+ throw new Error('jwtMiddleware: `secret` (or `jwksUrl`) is required')
102
+ }
103
+ }
104
+
105
+ const algorithms = (opts.algorithms ?? DEFAULT_ALGORITHMS).slice() as JwtAlgorithm[]
106
+ if (algorithms.length === 0) {
107
+ throw new Error('jwtMiddleware: `algorithms` must contain at least one algorithm')
108
+ }
109
+ for (const alg of algorithms) {
110
+ // 'none' is never permitted, even if a caller adds it to the allowlist.
111
+ if ((alg as unknown as string) === 'none') {
112
+ throw new Error('jwtMiddleware: `alg: "none"` is forbidden')
113
+ }
114
+ if (!SUPPORTED.has(alg)) {
115
+ throw new Error(`jwtMiddleware: unsupported algorithm ${String(alg)}`)
116
+ }
117
+ }
118
+
119
+ const required = opts.required ?? true
120
+ const clockSkewSeconds = opts.clockSkewSeconds ?? 5
121
+ const jwksCacheMs = opts.jwksCacheMs ?? DEFAULT_JWKS_TTL_MS
122
+ const jwksUrl = hasJwks ? opts.jwksUrl! : null
123
+ const getToken = opts.getToken ?? defaultGetToken
124
+ const logger = opts.logger ?? ((event) => {
125
+ process.emitWarning(`jwt verification failed: ${event.reason}`, 'IngeniumJwtWarning')
126
+ })
127
+
128
+ const staticKeys = opts.secret != null && typeof opts.secret !== 'function'
129
+ ? normaliseStaticKeys(opts.secret as JwtKey | JwtKey[])
130
+ : []
131
+ const keyResolver =
132
+ typeof opts.secret === 'function'
133
+ ? (opts.secret as JwtSecretResolver<T>)
134
+ : null
135
+
136
+ return async (ctx, next) => {
137
+ const token = await getToken(ctx)
138
+ if (!token) {
139
+ if (required) {
140
+ // Missing token IS allowed to leak (no secret material involved).
141
+ throw new IngeniumUnauthorizedError('Missing token')
142
+ }
143
+ await next()
144
+ return
145
+ }
146
+
147
+ // Peek the header so resolvers / JWKS can route by `kid`. Malformed
148
+ // tokens still reach the verifier so they get the canonical error.
149
+ const peeked = peekHeader(token)
150
+
151
+ // Build the candidate key list for this request.
152
+ let keys: (VerifyKeyMaterial | KidTaggedKey)[]
153
+ try {
154
+ keys = await collectKeys({
155
+ peeked,
156
+ staticKeys,
157
+ keyResolver,
158
+ jwksUrl,
159
+ jwksCacheMs,
160
+ })
161
+ } catch (err) {
162
+ if (err instanceof IngeniumUnauthorizedError) throw err
163
+ const reason = err instanceof Error ? err.message : 'key_resolution_failed'
164
+ logger({ reason })
165
+ throw new IngeniumUnauthorizedError('Invalid token')
166
+ }
167
+
168
+ if (keys.length === 0) {
169
+ logger({ reason: 'no_keys_available' })
170
+ throw new IngeniumUnauthorizedError('Invalid token')
171
+ }
172
+
173
+ // Build VerifyOptions without spreading undefined keys (exactOptionalPropertyTypes).
174
+ const verifyOpts: Parameters<typeof verifyJwt>[2] = { algorithms, clockSkewSeconds }
175
+ if (opts.audience !== undefined) verifyOpts.audience = opts.audience
176
+ if (opts.issuer !== undefined) verifyOpts.issuer = opts.issuer
177
+ if (opts.maxAgeSeconds !== undefined) verifyOpts.maxAgeSeconds = opts.maxAgeSeconds
178
+ const result = verifyJwt<T>(token, keys, verifyOpts)
179
+
180
+ if ('error' in result) {
181
+ logger({ reason: result.error })
182
+ throw new IngeniumUnauthorizedError('Invalid token')
183
+ }
184
+
185
+ ;(ctx as IngeniumContext & { jwt?: JwtVerified<T> }).jwt = result
186
+ await next()
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Normalise the static `secret` option to a flat array of either raw key
192
+ * material or kid-tagged entries that `verifyJwt` understands.
193
+ *
194
+ * Accepts string / Buffer / KeyObject and the `{ kid, key }` wrapper.
195
+ */
196
+ function normaliseStaticKeys(secret: JwtKey | JwtKey[]): (VerifyKeyMaterial | KidTaggedKey)[] {
197
+ const list = Array.isArray(secret) ? secret : [secret]
198
+ if (list.length === 0) {
199
+ // An empty literal array IS allowed when paired with jwksUrl; the
200
+ // top-level construction-time check already validated that constraint.
201
+ return []
202
+ }
203
+ const out: (VerifyKeyMaterial | KidTaggedKey)[] = []
204
+ for (const k of list) {
205
+ out.push(coerceJwtKey(k))
206
+ }
207
+ return out
208
+ }
209
+
210
+ function coerceJwtKey(k: JwtKey): VerifyKeyMaterial | KidTaggedKey {
211
+ if (typeof k === 'string') {
212
+ if (k.length === 0) {
213
+ throw new Error('jwtMiddleware: secret string must not be empty')
214
+ }
215
+ return k
216
+ }
217
+ if (Buffer.isBuffer(k)) return k
218
+ if (isKeyObject(k)) return k
219
+ if (typeof k === 'object' && k !== null && 'kid' in k && 'key' in k) {
220
+ if (typeof k.kid !== 'string' || k.kid.length === 0) {
221
+ throw new Error('jwtMiddleware: keyed entry requires a non-empty `kid`')
222
+ }
223
+ const key = k.key
224
+ if (typeof key === 'string') {
225
+ if (key.length === 0) throw new Error('jwtMiddleware: keyed entry `key` must not be empty')
226
+ return { kid: k.kid, key }
227
+ }
228
+ if (Buffer.isBuffer(key) || isKeyObject(key)) return { kid: k.kid, key }
229
+ }
230
+ throw new Error('jwtMiddleware: invalid `secret` entry — expected string, Buffer, KeyObject, or { kid, key }')
231
+ }
232
+
233
+ function isKeyObject(v: unknown): v is KeyObject {
234
+ // Avoid a hard import-time check; KeyObjects from node:crypto carry a
235
+ // distinctive `asymmetricKeyType` getter or `type` property of
236
+ // 'public' | 'private' | 'secret'.
237
+ if (typeof v !== 'object' || v === null) return false
238
+ const t = (v as { type?: unknown }).type
239
+ return t === 'public' || t === 'private' || t === 'secret'
240
+ }
241
+
242
+ interface CollectKeysCtx<T> {
243
+ peeked: JwtHeader | null
244
+ staticKeys: (VerifyKeyMaterial | KidTaggedKey)[]
245
+ keyResolver: JwtSecretResolver<T> | null
246
+ jwksUrl: string | null
247
+ jwksCacheMs: number
248
+ }
249
+
250
+ async function collectKeys<T>(ctx: CollectKeysCtx<T>): Promise<(VerifyKeyMaterial | KidTaggedKey)[]> {
251
+ const out: (VerifyKeyMaterial | KidTaggedKey)[] = ctx.staticKeys.slice()
252
+
253
+ if (ctx.keyResolver) {
254
+ const resolved = await ctx.keyResolver(ctx.peeked ?? ({ alg: '' } as JwtHeader))
255
+ if (resolved == null) {
256
+ throw new Error('secret_resolver_returned_empty')
257
+ }
258
+ if (typeof resolved === 'string') {
259
+ if (resolved.length === 0) throw new Error('secret_resolver_returned_empty')
260
+ out.push(resolved)
261
+ } else {
262
+ out.push(coerceJwtKey(resolved as JwtKey))
263
+ }
264
+ }
265
+
266
+ if (ctx.jwksUrl) {
267
+ let jwks: Map<string, KeyObject>
268
+ try {
269
+ jwks = await fetchJwks(ctx.jwksUrl, ctx.jwksCacheMs)
270
+ } catch {
271
+ // Don't leak upstream details to clients OR to the logger reason —
272
+ // a single canonical reason is enough for ops.
273
+ throw new IngeniumUnauthorizedError('Token key fetch failed')
274
+ }
275
+ const headerKid = ctx.peeked && typeof ctx.peeked.kid === 'string' ? ctx.peeked.kid : null
276
+ if (headerKid) {
277
+ const match = jwks.get(headerKid)
278
+ if (match) out.push({ kid: headerKid, key: match })
279
+ // No-match isn't fatal here; verifier returns kid_unknown if the
280
+ // entire candidate set is empty after key selection.
281
+ } else {
282
+ // No kid on the token — try every key in the JWKS as a tagged entry.
283
+ for (const [kid, key] of jwks) {
284
+ out.push({ kid, key })
285
+ }
286
+ // Also expose them untagged so the verifier's `selectCandidates` will
287
+ // try them when the header lacks `kid`.
288
+ for (const [, key] of jwks) {
289
+ out.push(key)
290
+ }
291
+ }
292
+ }
293
+
294
+ return out
295
+ }
296
+
297
+ /** Best-effort header peek for resolver routing. Returns null on malformed tokens. */
298
+ function peekHeader(token: string): JwtHeader | null {
299
+ const dot = token.indexOf('.')
300
+ if (dot <= 0) return null
301
+ try {
302
+ const buf = Buffer.from(token.slice(0, dot), 'base64url')
303
+ if (buf.length === 0) return null
304
+ const parsed = JSON.parse(buf.toString('utf8')) as unknown
305
+ if (parsed === null || typeof parsed !== 'object') return null
306
+ return parsed as JwtHeader
307
+ } catch {
308
+ return null
309
+ }
310
+ }
311
+
312
+ // Backwards-compat re-exports for downstream code that imported these names.
313
+ export type { JwtSecret, JwtSecretResolver }
@@ -0,0 +1,137 @@
1
+ import type { KeyObject } from 'node:crypto'
2
+ import type { Buffer } from 'node:buffer'
3
+ import type { IngeniumContext } from '../context/context.ts'
4
+
5
+ /**
6
+ * Supported JWT signing algorithms.
7
+ *
8
+ * - `HSxxx` — HMAC with the supplied shared secret.
9
+ * - `RSxxx` — RSASSA-PKCS1-v1_5 with the supplied RSA public key (PEM / JWK / KeyObject).
10
+ * - `PSxxx` — RSASSA-PSS (MGF1, salt length = digest length).
11
+ * - `ESxxx` — ECDSA on P-256 / P-384 / P-521 (raw r||s, NOT DER — per the JWT spec).
12
+ *
13
+ * `alg: 'none'` is intentionally absent from this union and is hard-rejected
14
+ * at the verifier — never accept unsigned tokens, even with an empty allowlist.
15
+ */
16
+ export type JwtAlgorithm =
17
+ | 'HS256' | 'HS384' | 'HS512'
18
+ | 'RS256' | 'RS384' | 'RS512'
19
+ | 'ES256' | 'ES384' | 'ES512'
20
+ | 'PS256' | 'PS384' | 'PS512'
21
+
22
+ /** Decoded JWT header. The `alg` field is required by the spec. */
23
+ export interface JwtHeader {
24
+ alg: string
25
+ typ?: string
26
+ kid?: string
27
+ [k: string]: unknown
28
+ }
29
+
30
+ /**
31
+ * A successfully verified JWT. `payload` carries the typed claims object,
32
+ * `header` carries the decoded protected header, and `raw` is the original
33
+ * compact-serialization string the client sent (useful for re-emitting).
34
+ */
35
+ export interface JwtVerified<T = Record<string, unknown>> {
36
+ header: JwtHeader
37
+ payload: T
38
+ raw: string
39
+ }
40
+
41
+ /** Internal — verifier failure mode. Public surface only ever sees `'Invalid token'`. */
42
+ export type JwtVerifyError =
43
+ | { error: 'malformed' }
44
+ | { error: 'unsupported_alg' }
45
+ | { error: 'bad_signature' }
46
+ | { error: 'expired' }
47
+ | { error: 'not_yet_valid' }
48
+ | { error: 'too_old' }
49
+ | { error: 'aud_mismatch' }
50
+ | { error: 'iss_mismatch' }
51
+ | { error: 'kid_unknown' }
52
+ | { error: 'jwks_fetch_failed' }
53
+
54
+ /**
55
+ * A single signing/verification key. For HMAC algorithms (HSxxx) this is the
56
+ * shared secret as a string; for asymmetric algorithms it's the PUBLIC key in
57
+ * PEM (string / Buffer) or as a pre-built `KeyObject`.
58
+ *
59
+ * Wrapping with `{ kid, key }` enables header-based key selection — the
60
+ * verifier picks the entry whose `kid` matches `header.kid`.
61
+ */
62
+ export type JwtKey =
63
+ | string
64
+ | Buffer
65
+ | KeyObject
66
+ | { kid: string; key: string | Buffer | KeyObject }
67
+
68
+ /**
69
+ * Resolve a per-request key. Receives the decoded JWT header so callers can
70
+ * implement `kid`-based JWKS-style routing without parsing the token themselves.
71
+ */
72
+ export type JwtSecretResolver<_T = Record<string, unknown>> = (
73
+ header: JwtHeader,
74
+ ) => JwtKey | Promise<JwtKey>
75
+
76
+ /** All ways `secret` can be supplied. */
77
+ export type JwtSecret<T = Record<string, unknown>> =
78
+ | JwtKey
79
+ | JwtKey[]
80
+ | JwtSecretResolver<T>
81
+
82
+ /** Signature for pulling the raw compact-serialization out of the request. */
83
+ export type JwtTokenReader = (
84
+ ctx: IngeniumContext,
85
+ ) => string | undefined | Promise<string | undefined>
86
+
87
+ /** Optional structured logger for redacted verification diagnostics. */
88
+ export type JwtLogger = (event: { reason: string; alg?: string }) => void
89
+
90
+ export interface JwtOptions<T = Record<string, unknown>> {
91
+ /**
92
+ * Verification key material. Accepts:
93
+ * - A single key (string secret, PEM, Buffer, or `KeyObject`).
94
+ * - `{ kid, key }` for explicit key-id tagging.
95
+ * - An array of any of the above (rotation / multi-key — the verifier
96
+ * picks by `kid` if present, else tries each in order).
97
+ * - A function `(header) => key | Promise<key>` for fully custom routing.
98
+ */
99
+ secret: JwtSecret<T>
100
+ /** Allowed signing algorithms. Default `['HS256']`. */
101
+ algorithms?: readonly JwtAlgorithm[]
102
+ /** Required `aud` claim. Token's `aud` must match (or include) one of these. */
103
+ audience?: string | readonly string[]
104
+ /** Required `iss` claim. */
105
+ issuer?: string | readonly string[]
106
+ /** Reject tokens whose `iat` is older than N seconds. */
107
+ maxAgeSeconds?: number
108
+ /** Leeway for `nbf` / `exp` checks, in seconds. Default `5`. */
109
+ clockSkewSeconds?: number
110
+ /**
111
+ * If `true` (default), missing tokens raise `IngeniumUnauthorizedError`.
112
+ * If `false`, missing tokens just call `next()` with no `ctx.jwt`.
113
+ */
114
+ required?: boolean
115
+ /**
116
+ * Custom token reader. Default reads `Authorization: Bearer <token>`.
117
+ * Return `undefined` to indicate "no token in this request".
118
+ */
119
+ getToken?: JwtTokenReader
120
+ /**
121
+ * Optional sink for redacted verification failure reasons. Useful for
122
+ * observability without leaking the failure type to the wire (which would
123
+ * be an oracle for attackers).
124
+ */
125
+ logger?: JwtLogger
126
+ /**
127
+ * Optional JWKS endpoint URL. When set, the middleware fetches the keys
128
+ * from this URL on demand and looks them up by `header.kid`. Cached for
129
+ * `jwksCacheMs` (default 10 minutes) per URL with a single in-flight
130
+ * request coalesced across concurrent callers.
131
+ */
132
+ jwksUrl?: string
133
+ /** JWKS cache TTL in milliseconds. Default `600_000` (10 minutes). */
134
+ jwksCacheMs?: number
135
+ /** Phantom — narrows `ctx.jwt.payload` for typed handlers. */
136
+ _payload?: T
137
+ }