galbe 0.15.6 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +377 -71
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +366 -90
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import type { MiddlewareDef, PreParseContext } from '../types'
|
|
2
|
+
import type { STOptional, STString } from '../schema'
|
|
3
|
+
import type { AuthErrorHandler } from './_auth'
|
|
4
|
+
|
|
5
|
+
import { $T } from '../index'
|
|
6
|
+
import { AuthError, authHook, bearerChallenge, checkRealm, readCredential, securityMetadata } from './_auth'
|
|
7
|
+
|
|
8
|
+
export { AuthError } from './_auth'
|
|
9
|
+
|
|
10
|
+
// `none` is deliberately absent, and nothing outside this list is ever accepted.
|
|
11
|
+
const ALGORITHMS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'] as const
|
|
12
|
+
/** JWS algorithms verifiable on `crypto.subtle`. */
|
|
13
|
+
export type JwtAlgorithm = (typeof ALGORITHMS)[number]
|
|
14
|
+
/** A JSON Web Key, structurally — `lib.dom` is not loaded and its `JsonWebKey` with it. */
|
|
15
|
+
export type JwtJsonWebKey = { kty?: string; alg?: string; crv?: string } & Record<string, any>
|
|
16
|
+
/**
|
|
17
|
+
* Verification key: an HMAC secret (string or bytes), a PEM-encoded SPKI public
|
|
18
|
+
* key, a JWK, or an already imported {@link CryptoKey}.
|
|
19
|
+
*/
|
|
20
|
+
export type JwtKey = string | Uint8Array | ArrayBuffer | JwtJsonWebKey | CryptoKey
|
|
21
|
+
/** Where a token is read from: the `Authorization: Bearer` header, or a named cookie. */
|
|
22
|
+
export type JwtSource = 'bearer' | `cookie:${string}`
|
|
23
|
+
/** Decoded token payload — registered claims typed, everything else passed through. */
|
|
24
|
+
export type JwtPayload = {
|
|
25
|
+
iss?: string
|
|
26
|
+
sub?: string
|
|
27
|
+
aud?: string | string[]
|
|
28
|
+
exp?: number
|
|
29
|
+
nbf?: number
|
|
30
|
+
iat?: number
|
|
31
|
+
jti?: string
|
|
32
|
+
} & Record<string, any>
|
|
33
|
+
/**
|
|
34
|
+
* Why verification failed — the three {@link AuthError} codes plus everything
|
|
35
|
+
* specific to a signed token. Available to {@link JwtConfig.errorHandler};
|
|
36
|
+
* never sent to the client.
|
|
37
|
+
*/
|
|
38
|
+
export type JwtErrorCode =
|
|
39
|
+
| 'missing'
|
|
40
|
+
| 'malformed'
|
|
41
|
+
| 'algorithm'
|
|
42
|
+
| 'signature'
|
|
43
|
+
| 'expired'
|
|
44
|
+
| 'immature'
|
|
45
|
+
| 'issuer'
|
|
46
|
+
| 'audience'
|
|
47
|
+
| 'subject'
|
|
48
|
+
| 'invalid'
|
|
49
|
+
|
|
50
|
+
/** Thrown internally on every rejection, and handed to {@link JwtConfig.errorHandler}. */
|
|
51
|
+
export class JwtError extends AuthError<JwtErrorCode> {
|
|
52
|
+
constructor(code: JwtErrorCode, message: string) {
|
|
53
|
+
super(code, message)
|
|
54
|
+
this.name = 'JwtError'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type JwtConfig = {
|
|
59
|
+
/**
|
|
60
|
+
* Key the signature is verified against: the HMAC secret for `HS*`, the
|
|
61
|
+
* public key for `RS*`/`ES*`. Accepts a PEM SPKI string, a JWK, raw bytes or
|
|
62
|
+
* a {@link CryptoKey}. Imported once, on the first request.
|
|
63
|
+
*/
|
|
64
|
+
key: JwtKey
|
|
65
|
+
/**
|
|
66
|
+
* Algorithms accepted, narrowing what the key already allows. Defaults to
|
|
67
|
+
* every algorithm of the key's family (`HS256`/`384`/`512` for a secret,
|
|
68
|
+
* `RS*` for an RSA key, the curve's `ES*` for an EC key). A token's own `alg`
|
|
69
|
+
* header never widens this set.
|
|
70
|
+
*/
|
|
71
|
+
algorithms?: JwtAlgorithm[]
|
|
72
|
+
/** Where to look for the token, in order. Default `['bearer']`. */
|
|
73
|
+
sources?: JwtSource[]
|
|
74
|
+
/** `ctx.state` key the verified payload is stored under. Default `jwtPayload`. */
|
|
75
|
+
stateHolder?: string
|
|
76
|
+
/**
|
|
77
|
+
* Lets a request carrying **no** token through unauthenticated instead of
|
|
78
|
+
* answering 401 — the public half of a route that personalizes a signed-in
|
|
79
|
+
* caller. A token that is present but fails any check is still rejected.
|
|
80
|
+
*/
|
|
81
|
+
optional?: boolean
|
|
82
|
+
/** Required `iss` claim — one value or a list of accepted ones. */
|
|
83
|
+
issuer?: string | string[]
|
|
84
|
+
/** Required `aud` claim: at least one of these must match the token's audience. */
|
|
85
|
+
audience?: string | string[]
|
|
86
|
+
/** Required `sub` claim. */
|
|
87
|
+
subject?: string
|
|
88
|
+
/** Seconds of clock skew tolerated on `exp` and `nbf`. Default `0`. */
|
|
89
|
+
clockTolerance?: number
|
|
90
|
+
/** Extra payload check run after the standard claims. Returning `false` rejects the request. */
|
|
91
|
+
validate?: (payload: JwtPayload, ctx: PreParseContext) => boolean | Promise<boolean>
|
|
92
|
+
/** Protection space named in the `WWW-Authenticate` challenge. Omitted by default. */
|
|
93
|
+
realm?: string
|
|
94
|
+
/**
|
|
95
|
+
* Replaces the default rejection. Return a `Response` to answer the request,
|
|
96
|
+
* or nothing to fall back to the default `401`; throwing takes the usual
|
|
97
|
+
* error handler path. Optional authentication is {@link JwtConfig.optional},
|
|
98
|
+
* not something an error handler expresses.
|
|
99
|
+
*/
|
|
100
|
+
errorHandler?: AuthErrorHandler<JwtError>
|
|
101
|
+
/**
|
|
102
|
+
* Name of the OpenAPI security scheme contributed — `bearerAuth` for the
|
|
103
|
+
* bearer source, `cookieAuth` for a cookie one. Rename it when two instances
|
|
104
|
+
* coexist in one app; `false` emits no security metadata at all.
|
|
105
|
+
*/
|
|
106
|
+
securityScheme?: string | false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The header contract a bearer-sourced instance imposes on every route it matches. */
|
|
110
|
+
export type JwtFragment = { headers: { authorization: STOptional<STString> } }
|
|
111
|
+
|
|
112
|
+
const encoder = new TextEncoder()
|
|
113
|
+
const decoder = new TextDecoder()
|
|
114
|
+
const HASHES = { '256': 'SHA-256', '384': 'SHA-384', '512': 'SHA-512' } as const
|
|
115
|
+
const CURVES = { '256': 'P-256', '384': 'P-384', '512': 'P-521' } as const
|
|
116
|
+
const PEM = /-----BEGIN ([A-Z ]+)-----([\s\S]+?)-----END/
|
|
117
|
+
const family = (alg: JwtAlgorithm) => alg.slice(0, 2) as 'HS' | 'RS' | 'ES'
|
|
118
|
+
const bits = (alg: JwtAlgorithm) => alg.slice(2) as keyof typeof HASHES
|
|
119
|
+
|
|
120
|
+
const b64ToBytes = (b64: string) => {
|
|
121
|
+
const bin = atob(b64.replace(/\s+/g, ''))
|
|
122
|
+
const bytes = new Uint8Array(bin.length)
|
|
123
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
124
|
+
return bytes
|
|
125
|
+
}
|
|
126
|
+
const b64uToBytes = (b64u: string) => b64ToBytes(b64u.replace(/-/g, '+').replace(/_/g, '/'))
|
|
127
|
+
const bytesToB64u = (bytes: Uint8Array) => {
|
|
128
|
+
let bin = ''
|
|
129
|
+
for (const byte of bytes) bin += String.fromCharCode(byte)
|
|
130
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
131
|
+
}
|
|
132
|
+
const strToB64u = (str: string) => bytesToB64u(encoder.encode(str))
|
|
133
|
+
|
|
134
|
+
// The curve is a property of an EC key, the hash a property of the operation;
|
|
135
|
+
// for HMAC and RSA both are fixed at import time.
|
|
136
|
+
const importParams = (alg: JwtAlgorithm) =>
|
|
137
|
+
family(alg) === 'HS'
|
|
138
|
+
? { name: 'HMAC', hash: HASHES[bits(alg)] }
|
|
139
|
+
: family(alg) === 'RS'
|
|
140
|
+
? { name: 'RSASSA-PKCS1-v1_5', hash: HASHES[bits(alg)] }
|
|
141
|
+
: { name: 'ECDSA', namedCurve: CURVES[bits(alg)] }
|
|
142
|
+
// what `crypto.subtle.sign`/`verify` take, as opposed to what the import takes
|
|
143
|
+
const operationParams = (alg: JwtAlgorithm) =>
|
|
144
|
+
family(alg) === 'ES' ? { name: 'ECDSA', hash: HASHES[bits(alg)] } : importParams(alg)
|
|
145
|
+
|
|
146
|
+
const isJwk = (key: JwtKey): key is JwtJsonWebKey =>
|
|
147
|
+
typeof key === 'object' && !(key instanceof CryptoKey) && !ArrayBuffer.isView(key) && !(key instanceof ArrayBuffer)
|
|
148
|
+
|
|
149
|
+
type KeyUsage = 'verify' | 'sign'
|
|
150
|
+
const importKey = (key: Exclude<JwtKey, CryptoKey>, alg: JwtAlgorithm, usage: KeyUsage): Promise<CryptoKey> => {
|
|
151
|
+
const params = importParams(alg)
|
|
152
|
+
if (isJwk(key)) return crypto.subtle.importKey('jwk', key, params, false, [usage])
|
|
153
|
+
// an HMAC secret is the raw bytes; an asymmetric key is a DER structure —
|
|
154
|
+
// SPKI for the public half, PKCS#8 for the private one, which is what a PEM
|
|
155
|
+
// label says outright and what the usage implies for bare bytes
|
|
156
|
+
const [, label, body] = typeof key === 'string' ? (key.match(PEM) ?? []) : []
|
|
157
|
+
if (label !== undefined) {
|
|
158
|
+
if (!/^(PUBLIC|PRIVATE) KEY$/.test(label))
|
|
159
|
+
throw new Error(`jwt: unsupported PEM '${label}', expected 'PUBLIC KEY' or 'PRIVATE KEY' (PKCS#8)`)
|
|
160
|
+
return crypto.subtle.importKey(label === 'PRIVATE KEY' ? 'pkcs8' : 'spki', b64ToBytes(body ?? ''), params, false, [
|
|
161
|
+
usage,
|
|
162
|
+
])
|
|
163
|
+
}
|
|
164
|
+
const der = usage === 'sign' ? 'pkcs8' : 'spki'
|
|
165
|
+
const bytes = typeof key === 'string' ? encoder.encode(key) : key
|
|
166
|
+
return crypto.subtle.importKey(family(alg) === 'HS' ? 'raw' : der, bytes, params, false, [usage])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Which algorithms the key itself can serve. The `alg` header of an incoming
|
|
171
|
+
* token is attacker-controlled — the key decides what is acceptable, never the
|
|
172
|
+
* token, which is what closes the classic RS256→HS256 confusion.
|
|
173
|
+
*/
|
|
174
|
+
const keyAlgorithms = async (key: JwtKey, usage: KeyUsage): Promise<JwtAlgorithm[]> => {
|
|
175
|
+
const of = (fam: 'HS' | 'RS', hash?: string) =>
|
|
176
|
+
hash
|
|
177
|
+
? ([`${fam}${hash.slice(4)}`] as JwtAlgorithm[])
|
|
178
|
+
: (['256', '384', '512'].map(b => `${fam}${b}`) as JwtAlgorithm[])
|
|
179
|
+
const ec = (crv?: string) => [`ES${crv === 'P-384' ? '384' : crv === 'P-521' ? '512' : '256'}`] as JwtAlgorithm[]
|
|
180
|
+
if (key instanceof CryptoKey) {
|
|
181
|
+
const { name } = key.algorithm
|
|
182
|
+
const { hash, namedCurve } = key.algorithm as { hash?: { name?: string }; namedCurve?: string }
|
|
183
|
+
if (name === 'HMAC') return of('HS', hash?.name)
|
|
184
|
+
if (name === 'RSASSA-PKCS1-v1_5') return of('RS', hash?.name)
|
|
185
|
+
if (name === 'ECDSA') return ec(namedCurve)
|
|
186
|
+
throw new Error(`jwt: unsupported key algorithm '${name}'`)
|
|
187
|
+
}
|
|
188
|
+
if (isJwk(key)) {
|
|
189
|
+
if (key.alg) return [key.alg as JwtAlgorithm]
|
|
190
|
+
if (key.kty === 'RSA') return of('RS')
|
|
191
|
+
if (key.kty === 'EC') return ec(key.crv)
|
|
192
|
+
return of('HS')
|
|
193
|
+
}
|
|
194
|
+
// A PEM says nothing about the algorithm, so the platform's key parser
|
|
195
|
+
// answers instead: whichever import succeeds identifies the key's family.
|
|
196
|
+
if (typeof key === 'string' && PEM.test(key)) {
|
|
197
|
+
for (const alg of ['RS256', 'ES256', 'ES384', 'ES512'] as JwtAlgorithm[]) {
|
|
198
|
+
try {
|
|
199
|
+
await importKey(key, alg, usage)
|
|
200
|
+
return family(alg) === 'RS' ? of('RS') : [alg]
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
throw new Error('jwt: the PEM key is not a supported RSA or EC key')
|
|
204
|
+
}
|
|
205
|
+
return of('HS')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Keys and their algorithm set are resolved lazily, then cached per algorithm:
|
|
209
|
+
// the middleware factory is synchronous and importing is not.
|
|
210
|
+
const keyStore = (key: JwtKey, restrict: JwtAlgorithm[] | undefined, usage: KeyUsage) => {
|
|
211
|
+
const keys = new Map<JwtAlgorithm, Promise<CryptoKey>>()
|
|
212
|
+
let algorithms: Promise<JwtAlgorithm[]> | undefined
|
|
213
|
+
return {
|
|
214
|
+
algorithms: () =>
|
|
215
|
+
(algorithms ??= keyAlgorithms(key, usage).then(detected => {
|
|
216
|
+
// a key can carry an algorithm this middleware does not verify (an
|
|
217
|
+
// HMAC-SHA1 CryptoKey, a JWK with an exotic `alg`): drop those here
|
|
218
|
+
const supported = detected.filter(a => ALGORITHMS.includes(a))
|
|
219
|
+
const algs = restrict ? restrict.filter(a => supported.includes(a)) : supported
|
|
220
|
+
if (!algs.length)
|
|
221
|
+
throw new Error(`jwt: no supported algorithm for the configured key${restrict ? ` among [${restrict}]` : ''}`)
|
|
222
|
+
return algs
|
|
223
|
+
})),
|
|
224
|
+
key: (alg: JwtAlgorithm) => {
|
|
225
|
+
let imported = keys.get(alg)
|
|
226
|
+
if (!imported)
|
|
227
|
+
keys.set(alg, (imported = key instanceof CryptoKey ? Promise.resolve(key) : importKey(key, alg, usage)))
|
|
228
|
+
return imported
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* #### jwt
|
|
235
|
+
* Verify-only JWT middleware. Reads a token from the `Authorization: Bearer`
|
|
236
|
+
* header (or a cookie), verifies its signature on `crypto.subtle`, checks the
|
|
237
|
+
* standard claims and puts the payload on `ctx.state.jwtPayload`.
|
|
238
|
+
*
|
|
239
|
+
* It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
|
|
240
|
+
* slot, so an unauthenticated request is answered **401 before its body is
|
|
241
|
+
* read** — never a 400 that would leak the route's schema. Rejections are plain
|
|
242
|
+
* `401`s with a `WWW-Authenticate` challenge; the reason stays server-side, in
|
|
243
|
+
* the {@link JwtError} handed to `errorHandler`.
|
|
244
|
+
*
|
|
245
|
+
* The def also carries what the request contract and the spec need: matched
|
|
246
|
+
* routes gain an optional `authorization` header (`format: JWT`) and a
|
|
247
|
+
* `bearerAuth` security requirement.
|
|
248
|
+
*
|
|
249
|
+
* > Verification only — issuing tokens is not a request-time concern. Use
|
|
250
|
+
* > {@link signJwt} on the endpoint that hands them out.
|
|
251
|
+
*
|
|
252
|
+
* ---
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* import { jwt } from 'galbe/middlewares'
|
|
256
|
+
*
|
|
257
|
+
* // every /api route, verified with an HMAC secret
|
|
258
|
+
* galbe.middleware('/api/*', jwt({ key: Bun.env.JWT_SECRET! }))
|
|
259
|
+
*
|
|
260
|
+
* // the public key of an RS256 signer, issuer pinned, admin role required
|
|
261
|
+
* galbe.middleware('/admin/*', jwt({
|
|
262
|
+
* key: await Bun.file('public.pem').text(),
|
|
263
|
+
* issuer: 'https://auth.example.com',
|
|
264
|
+
* audience: 'my-api',
|
|
265
|
+
* validate: payload => payload.role === 'admin'
|
|
266
|
+
* }))
|
|
267
|
+
*
|
|
268
|
+
* // a token is welcome but not required
|
|
269
|
+
* galbe.middleware('/feed/*', jwt({ key: Bun.env.JWT_SECRET!, optional: true }))
|
|
270
|
+
*
|
|
271
|
+
* galbe.get('/api/me', ctx => ctx.state.jwtPayload.sub)
|
|
272
|
+
* ```
|
|
273
|
+
* @param config - see {@link JwtConfig}
|
|
274
|
+
*/
|
|
275
|
+
export const jwt = (config: JwtConfig): MiddlewareDef<JwtFragment> => {
|
|
276
|
+
const sources = config.sources ?? ['bearer']
|
|
277
|
+
const stateHolder = config.stateHolder ?? 'jwtPayload'
|
|
278
|
+
const issuers = config.issuer === undefined ? undefined : [config.issuer].flat()
|
|
279
|
+
const audiences = config.audience === undefined ? undefined : [config.audience].flat()
|
|
280
|
+
const tolerance = config.clockTolerance ?? 0
|
|
281
|
+
const store = keyStore(config.key, config.algorithms, 'verify')
|
|
282
|
+
for (const source of sources)
|
|
283
|
+
if (source !== 'bearer' && !source.startsWith('cookie:'))
|
|
284
|
+
throw new SyntaxError(`jwt: invalid source '${source}', expected 'bearer' or 'cookie:<name>'`)
|
|
285
|
+
const realm = checkRealm('jwt', config.realm)
|
|
286
|
+
const bearer = sources.includes('bearer')
|
|
287
|
+
|
|
288
|
+
const read = (ctx: PreParseContext) => {
|
|
289
|
+
for (const source of sources) {
|
|
290
|
+
const token =
|
|
291
|
+
source === 'bearer'
|
|
292
|
+
? readCredential(ctx, 'header', 'authorization', 'Bearer ')
|
|
293
|
+
: readCredential(ctx, 'cookie', source.slice(7))
|
|
294
|
+
if (token) return token
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const verify = async (token: string): Promise<JwtPayload> => {
|
|
299
|
+
const [head, body, sig, ...rest] = token.split('.')
|
|
300
|
+
if (sig === undefined || rest.length) throw new JwtError('malformed', 'token is not a JWS compact serialization')
|
|
301
|
+
let header: any, payload: any, signature: Uint8Array
|
|
302
|
+
try {
|
|
303
|
+
header = JSON.parse(decoder.decode(b64uToBytes(head!)))
|
|
304
|
+
payload = JSON.parse(decoder.decode(b64uToBytes(body!)))
|
|
305
|
+
signature = b64uToBytes(sig)
|
|
306
|
+
} catch {
|
|
307
|
+
throw new JwtError('malformed', 'token header, payload or signature is not decodable')
|
|
308
|
+
}
|
|
309
|
+
const alg = header?.alg
|
|
310
|
+
const algorithms = await store.algorithms()
|
|
311
|
+
if (!algorithms.includes(alg)) throw new JwtError('algorithm', `algorithm '${alg}' is not accepted`)
|
|
312
|
+
const verified = await crypto.subtle.verify(
|
|
313
|
+
operationParams(alg),
|
|
314
|
+
await store.key(alg),
|
|
315
|
+
signature,
|
|
316
|
+
encoder.encode(`${head}.${body}`)
|
|
317
|
+
)
|
|
318
|
+
if (!verified) throw new JwtError('signature', 'signature does not match')
|
|
319
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
|
|
320
|
+
throw new JwtError('malformed', 'payload is not a JSON object')
|
|
321
|
+
return payload
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const checkClaims = (payload: JwtPayload) => {
|
|
325
|
+
const now = Date.now() / 1000
|
|
326
|
+
if (payload.exp !== undefined && !(now - tolerance < payload.exp))
|
|
327
|
+
throw new JwtError('expired', 'token has expired')
|
|
328
|
+
if (payload.nbf !== undefined && !(now + tolerance >= payload.nbf))
|
|
329
|
+
throw new JwtError('immature', 'token is not valid yet')
|
|
330
|
+
if (issuers && !issuers.includes(payload.iss!)) throw new JwtError('issuer', `unexpected issuer '${payload.iss}'`)
|
|
331
|
+
if (audiences && ![payload.aud ?? []].flat().some(a => audiences.includes(a)))
|
|
332
|
+
throw new JwtError('audience', `unexpected audience '${payload.aud}'`)
|
|
333
|
+
if (config.subject !== undefined && payload.sub !== config.subject)
|
|
334
|
+
throw new JwtError('subject', `unexpected subject '${payload.sub}'`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const beforeParse = authHook(
|
|
338
|
+
async ctx => {
|
|
339
|
+
const token = read(ctx)
|
|
340
|
+
if (!token) {
|
|
341
|
+
// optional authentication: nothing to verify is not a rejection
|
|
342
|
+
if (config.optional) return
|
|
343
|
+
throw new JwtError('missing', 'no token found in the request')
|
|
344
|
+
}
|
|
345
|
+
const payload = await verify(token)
|
|
346
|
+
checkClaims(payload)
|
|
347
|
+
if (config.validate && !(await config.validate(payload, ctx)))
|
|
348
|
+
throw new JwtError('invalid', 'payload rejected by validate()')
|
|
349
|
+
ctx.state[stateHolder] = payload
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
errorHandler: config.errorHandler,
|
|
353
|
+
// a cookie-sourced token has no challenge to answer with
|
|
354
|
+
challenge: bearer ? error => bearerChallenge(realm, error.code) : undefined,
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// One scheme per source, so a cookie-sourced token documents as the cookie it is
|
|
359
|
+
const { security, securitySchemes } = securityMetadata(
|
|
360
|
+
sources.map(source =>
|
|
361
|
+
source === 'bearer'
|
|
362
|
+
? { name: 'bearerAuth', scheme: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } as const }
|
|
363
|
+
: { name: 'cookieAuth', scheme: { type: 'apiKey', in: 'cookie', name: source.slice(7) } as const }
|
|
364
|
+
),
|
|
365
|
+
config.securityScheme
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
// the pattern is case-insensitive because the scheme name is (RFC 9110 §11.1)
|
|
369
|
+
// and the hook reads it that way: a fragment that rejected `bearer <token>`
|
|
370
|
+
// would 400 a request its own middleware just authenticated
|
|
371
|
+
const authorization = $T.optional($T.string({ pattern: /^Bearer /i, format: 'JWT' }))
|
|
372
|
+
return {
|
|
373
|
+
beforeParse,
|
|
374
|
+
...(bearer ? { schema: { headers: { authorization } } } : {}),
|
|
375
|
+
...(security.length ? { security, securitySchemes } : {}),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export type JwtSignOptions = {
|
|
380
|
+
/**
|
|
381
|
+
* Algorithm to sign with. Defaults to the key's own — `HS256` for a secret,
|
|
382
|
+
* `RS256` for an RSA key, the curve's `ES*` for an EC one.
|
|
383
|
+
*/
|
|
384
|
+
algorithm?: JwtAlgorithm
|
|
385
|
+
/** Lifetime in seconds, from now: sets `exp`. */
|
|
386
|
+
expiresIn?: number
|
|
387
|
+
/** Delay in seconds, from now, before the token becomes valid: sets `nbf`. */
|
|
388
|
+
notBefore?: number
|
|
389
|
+
/** Sets the `iss` claim. */
|
|
390
|
+
issuer?: string
|
|
391
|
+
/** Sets the `aud` claim. */
|
|
392
|
+
audience?: string | string[]
|
|
393
|
+
/** Sets the `sub` claim. */
|
|
394
|
+
subject?: string
|
|
395
|
+
/** Extra JOSE header fields, `kid` typically. `alg` is always the one signed with. */
|
|
396
|
+
header?: Record<string, any>
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* #### signJwt
|
|
401
|
+
* Sign a payload into a compact JWS token — the counterpart of {@link jwt},
|
|
402
|
+
* kept out of the middleware so a request path never holds a signing key.
|
|
403
|
+
* Signs on `crypto.subtle`, with the same key forms and algorithms `jwt`
|
|
404
|
+
* verifies.
|
|
405
|
+
*
|
|
406
|
+
* `iat` is set automatically, and the option shorthands (`expiresIn`,
|
|
407
|
+
* `issuer`, ...) fill their claims; a claim written in `payload` always wins
|
|
408
|
+
* over the option that would have set it.
|
|
409
|
+
*
|
|
410
|
+
* > Import the key once — `crypto.subtle.importKey(...)` — and pass the
|
|
411
|
+
* > `CryptoKey` when signing on a hot path: every other key form is imported
|
|
412
|
+
* > per call.
|
|
413
|
+
*
|
|
414
|
+
* ---
|
|
415
|
+
* @example
|
|
416
|
+
* ```typescript
|
|
417
|
+
* import { signJwt } from 'galbe/middlewares'
|
|
418
|
+
*
|
|
419
|
+
* galbe.post('/login', async ctx => {
|
|
420
|
+
* const user = await authenticate(ctx.body)
|
|
421
|
+
* return { token: await signJwt({ sub: user.id, role: user.role }, Bun.env.JWT_SECRET!, {
|
|
422
|
+
* expiresIn: 3600,
|
|
423
|
+
* issuer: 'https://auth.example.com'
|
|
424
|
+
* }) }
|
|
425
|
+
* })
|
|
426
|
+
* ```
|
|
427
|
+
* @param payload - claims to sign
|
|
428
|
+
* @param privateKey - HMAC secret, PKCS#8 PEM private key, JWK or `CryptoKey`
|
|
429
|
+
* @param options - see {@link JwtSignOptions}
|
|
430
|
+
*/
|
|
431
|
+
export const signJwt = async (
|
|
432
|
+
payload: JwtPayload,
|
|
433
|
+
privateKey: JwtKey,
|
|
434
|
+
options: JwtSignOptions = {}
|
|
435
|
+
): Promise<string> => {
|
|
436
|
+
if (privateKey instanceof CryptoKey && !privateKey.usages.includes('sign'))
|
|
437
|
+
throw new Error("jwt: the CryptoKey given to signJwt() was not imported with the 'sign' usage")
|
|
438
|
+
const store = keyStore(privateKey, options.algorithm && [options.algorithm], 'sign')
|
|
439
|
+
const [alg] = await store.algorithms()
|
|
440
|
+
const now = Math.floor(Date.now() / 1000)
|
|
441
|
+
const claims: JwtPayload = { iat: now }
|
|
442
|
+
if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn
|
|
443
|
+
if (options.notBefore !== undefined) claims.nbf = now + options.notBefore
|
|
444
|
+
if (options.issuer !== undefined) claims.iss = options.issuer
|
|
445
|
+
if (options.audience !== undefined) claims.aud = options.audience
|
|
446
|
+
if (options.subject !== undefined) claims.sub = options.subject
|
|
447
|
+
const head = strToB64u(JSON.stringify({ typ: 'JWT', ...options.header, alg: alg! }))
|
|
448
|
+
const body = strToB64u(JSON.stringify({ ...claims, ...payload }))
|
|
449
|
+
const signature = await crypto.subtle.sign(
|
|
450
|
+
operationParams(alg!),
|
|
451
|
+
await store.key(alg!),
|
|
452
|
+
encoder.encode(`${head}.${body}`)
|
|
453
|
+
)
|
|
454
|
+
return `${head}.${body}.${bytesToB64u(new Uint8Array(signature))}`
|
|
455
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Context, MiddlewareDef, PreParseHook, ResponseHook } from '../types'
|
|
2
|
+
|
|
3
|
+
import { METHOD_COLOR } from '../util'
|
|
4
|
+
|
|
5
|
+
/** One finished request, as the logger saw it. */
|
|
6
|
+
export type LogEntry = {
|
|
7
|
+
method: string
|
|
8
|
+
/** Request path, **without** the query string — see {@link LoggerConfig.log}. */
|
|
9
|
+
path: string
|
|
10
|
+
status: number
|
|
11
|
+
/** Milliseconds from the pre-parse slot to the parsed response — body parsing and validation included. */
|
|
12
|
+
duration: number
|
|
13
|
+
/** Whatever `requestId` left on the state, when it is registered ahead of this. */
|
|
14
|
+
requestId?: string
|
|
15
|
+
/** The error the request ended on, when it ended on one. */
|
|
16
|
+
error?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type LoggerConfig = {
|
|
20
|
+
/**
|
|
21
|
+
* Receives every finished request instead of the default console line — the
|
|
22
|
+
* hook into your own logger, structured or not.
|
|
23
|
+
*
|
|
24
|
+
* The entry's `path` carries no query string, as a query can hold an API key
|
|
25
|
+
* or a token; `ctx.request.url` has the whole thing when you want it.
|
|
26
|
+
*/
|
|
27
|
+
log?: (entry: LogEntry, ctx: Context) => void
|
|
28
|
+
/**
|
|
29
|
+
* Leaves a request unlogged — health checks, asset routes. It runs once the
|
|
30
|
+
* request is done, so the response status is readable and a filter can keep
|
|
31
|
+
* only the failures.
|
|
32
|
+
*/
|
|
33
|
+
skip?: (ctx: Context) => boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESET = '\x1b[0m'
|
|
37
|
+
const DIM = '\x1b[2m'
|
|
38
|
+
const statusColor = (status: number) =>
|
|
39
|
+
status >= 500 ? '\x1b[31m' : status >= 400 ? '\x1b[33m' : status >= 300 ? '\x1b[36m' : '\x1b[32m'
|
|
40
|
+
const paint = (color: string, text: string) => (Bun.enableANSIColors ? `${color}${text}${RESET}` : text)
|
|
41
|
+
|
|
42
|
+
const consoleLog = ({ method, path, status, duration, requestId }: LogEntry) =>
|
|
43
|
+
console.log(
|
|
44
|
+
`${paint(METHOD_COLOR[method.toLowerCase()] ?? '', method)} ${path} ` +
|
|
45
|
+
`${paint(statusColor(status), String(status))} ${paint(DIM, `${duration.toFixed(1)}ms`)}` +
|
|
46
|
+
(requestId ? ` ${paint(DIM, requestId)}` : '')
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
/** The path alone: no origin, no query string, and never a `URL` allocation on the request path. */
|
|
50
|
+
const pathOf = (url: string) => {
|
|
51
|
+
const start = url.indexOf('/', url.indexOf('://') + 3)
|
|
52
|
+
if (start < 0) return '/'
|
|
53
|
+
const query = url.indexOf('?', start)
|
|
54
|
+
return query < 0 ? url.slice(start) : url.slice(start, query)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* #### logger
|
|
59
|
+
* Logs one line per request — method, path, status and how long it took — or
|
|
60
|
+
* hands the same fields to your own logger through `log`.
|
|
61
|
+
*
|
|
62
|
+
* It fills two slots: the
|
|
63
|
+
* [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
|
|
64
|
+
* one starts the clock, and the `afterHandle` one reports the request once a
|
|
65
|
+
* `Response` exists. That covers what a hook-chain log cannot see — a `400`
|
|
66
|
+
* from validation, a `401` from an auth middleware, a `500` — with the status
|
|
67
|
+
* the request actually ended on and the error it ended on.
|
|
68
|
+
*
|
|
69
|
+
* What no middleware can see is a request that matched **no route**: a `404`, a
|
|
70
|
+
* CORS preflight, or a request a plugin answered in `onFetch`. An access log
|
|
71
|
+
* covering those belongs in the plugin.
|
|
72
|
+
*
|
|
73
|
+
* ---
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* import { logger } from 'galbe/middlewares'
|
|
77
|
+
*
|
|
78
|
+
* // a line per request, on the console
|
|
79
|
+
* galbe.middleware(logger())
|
|
80
|
+
*
|
|
81
|
+
* // structured, and quiet about the health check
|
|
82
|
+
* galbe.middleware(logger({
|
|
83
|
+
* log: entry => log.info(entry),
|
|
84
|
+
* skip: ctx => ctx.route?.path === '/health'
|
|
85
|
+
* }))
|
|
86
|
+
* ```
|
|
87
|
+
* @param config - see {@link LoggerConfig}
|
|
88
|
+
*/
|
|
89
|
+
export const logger = (config: LoggerConfig = {}): MiddlewareDef<{}> => {
|
|
90
|
+
const write = config.log ?? consoleLog
|
|
91
|
+
// the clock lives beside the request rather than on `ctx.state`, which is a
|
|
92
|
+
// public `Record<string, any>`: an internal marker has no business in a dump
|
|
93
|
+
// of it, and two instances on one route keep their own
|
|
94
|
+
const starts = new WeakMap<object, number>()
|
|
95
|
+
|
|
96
|
+
const beforeParse: PreParseHook = ctx => {
|
|
97
|
+
starts.set(ctx, performance.now())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const afterHandle: ResponseHook = (response, ctx, error) => {
|
|
101
|
+
const start = starts.get(ctx)
|
|
102
|
+
// the post slot also runs for a request answered before the pre slot did —
|
|
103
|
+
// a plugin that threw while routing — where there is no clock to read
|
|
104
|
+
if (typeof start !== 'number' || config.skip?.(ctx)) return
|
|
105
|
+
const id = ctx.state.requestId
|
|
106
|
+
write(
|
|
107
|
+
{
|
|
108
|
+
method: ctx.request.method,
|
|
109
|
+
path: pathOf(ctx.request.url),
|
|
110
|
+
status: response.status,
|
|
111
|
+
duration: performance.now() - start,
|
|
112
|
+
...(typeof id === 'string' ? { requestId: id } : {}),
|
|
113
|
+
...(error !== undefined ? { error } : {}),
|
|
114
|
+
},
|
|
115
|
+
ctx
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { beforeParse, afterHandle }
|
|
120
|
+
}
|