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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
package/src/jwt/jwks.ts
ADDED
|
@@ -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 }
|
package/src/jwt/types.ts
ADDED
|
@@ -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
|
+
}
|