0http-bun 1.1.3 → 1.2.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.
@@ -0,0 +1,406 @@
1
+ const {jwtVerify, createRemoteJWKSet, errors} = require('jose')
2
+
3
+ /**
4
+ * Creates JWT authentication middleware
5
+ * @param {Object} options - JWT configuration options
6
+ * @param {string|Uint8Array|Function} options.secret - JWT secret or key getter function
7
+ * @param {string} options.jwksUri - JWKS URI for remote key verification
8
+ * @param {Object} options.jwtOptions - Additional JWT verification options
9
+ * @param {Function} options.getToken - Custom token extraction function
10
+ * @param {string} options.tokenHeader - Custom header for token extraction
11
+ * @param {string} options.tokenQuery - Query parameter name for token extraction
12
+ * @param {boolean} options.optional - Whether authentication is optional
13
+ * @param {Array<string>} options.excludePaths - Paths to exclude from authentication
14
+ * @param {Array<string>|Function} options.apiKeys - Valid API keys for API key authentication
15
+ * @param {string} options.apiKeyHeader - Header name for API key
16
+ * @param {Function} options.apiKeyValidator - Custom API key validation function
17
+ * @param {Function} options.unauthorizedResponse - Custom unauthorized response generator
18
+ * @param {Function} options.onError - Custom error handler
19
+ * @param {string|Array<string>} options.audience - Expected JWT audience
20
+ * @param {string} options.issuer - Expected JWT issuer
21
+ * @returns {Function} Middleware function
22
+ */
23
+ function createJWTAuth(options = {}) {
24
+ const {
25
+ secret,
26
+ jwksUri,
27
+ jwks,
28
+ jwtOptions = {},
29
+ getToken,
30
+ tokenHeader,
31
+ tokenQuery,
32
+ optional = false,
33
+ excludePaths = [],
34
+ apiKeys,
35
+ apiKeyHeader = 'x-api-key',
36
+ apiKeyValidator,
37
+ validateApiKey,
38
+ unauthorizedResponse,
39
+ onError,
40
+ audience,
41
+ issuer,
42
+ algorithms,
43
+ } = options
44
+
45
+ // API key mode doesn't require JWT secret
46
+ const hasApiKeyMode = apiKeys || apiKeyValidator || validateApiKey
47
+ if (!secret && !jwksUri && !jwks && !hasApiKeyMode) {
48
+ throw new Error('JWT middleware requires either secret or jwksUri')
49
+ }
50
+
51
+ // Setup key resolver for JWT
52
+ let keyLike
53
+ if (jwks) {
54
+ // If jwks is a mock or custom resolver with getKey method
55
+ if (typeof jwks.getKey === 'function') {
56
+ keyLike = async (protectedHeader, token) => {
57
+ return jwks.getKey(protectedHeader, token)
58
+ }
59
+ } else {
60
+ keyLike = jwks
61
+ }
62
+ } else if (jwksUri) {
63
+ keyLike = createRemoteJWKSet(new URL(jwksUri))
64
+ } else if (typeof secret === 'function') {
65
+ keyLike = secret
66
+ } else {
67
+ keyLike = secret
68
+ }
69
+
70
+ // Default JWT verification options
71
+ const defaultJwtOptions = {
72
+ algorithms: algorithms || ['HS256', 'RS256'],
73
+ audience,
74
+ issuer,
75
+ ...jwtOptions,
76
+ }
77
+
78
+ return async function jwtAuthMiddleware(req, next) {
79
+ const url = new URL(req.url)
80
+
81
+ // Skip authentication for excluded paths
82
+ if (excludePaths.some((path) => url.pathname.startsWith(path))) {
83
+ return next()
84
+ }
85
+
86
+ try {
87
+ // Try API key authentication first if configured
88
+ if (hasApiKeyMode) {
89
+ const apiKey = req.headers.get(apiKeyHeader)
90
+ if (apiKey) {
91
+ const validationResult = await validateApiKeyInternal(
92
+ apiKey,
93
+ apiKeys,
94
+ apiKeyValidator || validateApiKey,
95
+ req,
96
+ )
97
+ if (validationResult !== false) {
98
+ // Set API key context
99
+ req.ctx = req.ctx || {}
100
+ req.ctx.apiKey = apiKey
101
+
102
+ // If validation result is an object, use it as user data, otherwise default
103
+ const userData =
104
+ validationResult && typeof validationResult === 'object'
105
+ ? validationResult
106
+ : {apiKey}
107
+
108
+ req.ctx.user = userData
109
+ req.apiKey = apiKey
110
+ req.user = userData
111
+ return next()
112
+ } else {
113
+ return handleAuthError(
114
+ new Error('Invalid API key'),
115
+ {unauthorizedResponse, onError},
116
+ req,
117
+ )
118
+ }
119
+ }
120
+ }
121
+
122
+ // Extract JWT token from request
123
+ const token = extractToken(req, {getToken, tokenHeader, tokenQuery})
124
+
125
+ if (!token) {
126
+ if (optional) {
127
+ return next()
128
+ }
129
+ return handleAuthError(
130
+ new Error('Authentication required'),
131
+ {unauthorizedResponse, onError},
132
+ req,
133
+ )
134
+ }
135
+
136
+ // Only verify JWT if we have JWT configuration
137
+ if (!keyLike) {
138
+ return handleAuthError(
139
+ new Error('JWT verification not configured'),
140
+ {unauthorizedResponse, onError},
141
+ req,
142
+ )
143
+ }
144
+
145
+ // Verify JWT token
146
+ const {payload, protectedHeader} = await jwtVerify(
147
+ token,
148
+ keyLike,
149
+ defaultJwtOptions,
150
+ )
151
+
152
+ // Add user info to request context
153
+ req.ctx = req.ctx || {}
154
+ req.ctx.user = payload
155
+ req.ctx.jwt = {
156
+ payload,
157
+ header: protectedHeader,
158
+ token,
159
+ }
160
+ req.user = payload // Mirror to root for compatibility
161
+ req.jwt = {
162
+ payload,
163
+ header: protectedHeader,
164
+ token,
165
+ }
166
+
167
+ return next()
168
+ } catch (error) {
169
+ if (optional && (!hasApiKeyMode || !req.headers.get(apiKeyHeader))) {
170
+ return next()
171
+ }
172
+
173
+ return handleAuthError(error, {unauthorizedResponse, onError}, req)
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Validates API key
180
+ * @param {string} apiKey - API key to validate
181
+ * @param {Array<string>|Function} apiKeys - Valid API keys or validator function
182
+ * @param {Function} apiKeyValidator - Custom validator function
183
+ * @param {Request} req - Request object
184
+ * @returns {boolean|Object} Whether API key is valid or user object
185
+ */
186
+ async function validateApiKeyInternal(apiKey, apiKeys, apiKeyValidator, req) {
187
+ if (apiKeyValidator) {
188
+ // Check if this is the simplified validateApiKey function (expects only key)
189
+ if (apiKeyValidator.length === 1) {
190
+ const result = await apiKeyValidator(apiKey)
191
+ return result || false
192
+ }
193
+ // Otherwise call with both key and req
194
+ const result = await apiKeyValidator(apiKey, req)
195
+ return result || false
196
+ }
197
+
198
+ if (typeof apiKeys === 'function') {
199
+ const result = await apiKeys(apiKey, req)
200
+ return result || false
201
+ }
202
+
203
+ if (Array.isArray(apiKeys)) {
204
+ return apiKeys.includes(apiKey)
205
+ }
206
+
207
+ return apiKeys === apiKey
208
+ }
209
+
210
+ /**
211
+ * Extracts JWT token from request
212
+ * @param {Request} req - Request object
213
+ * @param {Object} options - Extraction options
214
+ * @returns {string|null} JWT token or null if not found
215
+ */
216
+ function extractToken(req, options = {}) {
217
+ const {getToken, tokenHeader, tokenQuery} = options
218
+
219
+ // Use custom token extractor if provided
220
+ if (getToken) {
221
+ return getToken(req)
222
+ }
223
+
224
+ // Try custom header
225
+ if (tokenHeader) {
226
+ const token = req.headers.get(tokenHeader)
227
+ if (token) return token
228
+ }
229
+
230
+ // Try query parameter
231
+ if (tokenQuery) {
232
+ const url = new URL(req.url)
233
+ const token = url.searchParams.get(tokenQuery)
234
+ if (token) return token
235
+ }
236
+
237
+ // Default: Authorization header
238
+ return extractTokenFromHeader(req)
239
+ }
240
+
241
+ /**
242
+ * Handles authentication errors
243
+ * @param {Error} error - Authentication error
244
+ * @param {Object} handlers - Error handling functions
245
+ * @param {Request} req - Request object
246
+ * @returns {Response} Error response
247
+ */
248
+ function handleAuthError(error, handlers = {}, req) {
249
+ const {unauthorizedResponse, onError} = handlers
250
+
251
+ // Call custom error handler if provided
252
+ if (onError) {
253
+ try {
254
+ const result = onError(error, req)
255
+ if (result instanceof Response) {
256
+ return result
257
+ }
258
+ } catch (handlerError) {
259
+ // Fall back to default handling if custom handler fails
260
+ }
261
+ }
262
+
263
+ // Use custom unauthorized response if provided
264
+ if (unauthorizedResponse) {
265
+ try {
266
+ // If it's already a Response object, return it directly
267
+ if (unauthorizedResponse instanceof Response) {
268
+ return unauthorizedResponse
269
+ }
270
+
271
+ // If it's a function, call it
272
+ if (typeof unauthorizedResponse === 'function') {
273
+ const response = unauthorizedResponse(error, req)
274
+ if (response instanceof Response) {
275
+ return response
276
+ }
277
+ // If not a Response object, treat as response data
278
+ if (response && typeof response === 'object') {
279
+ return new Response(
280
+ typeof response.body === 'string'
281
+ ? response.body
282
+ : JSON.stringify(response.body || response),
283
+ {
284
+ status: response.status || 401,
285
+ headers: response.headers || {'Content-Type': 'application/json'},
286
+ },
287
+ )
288
+ }
289
+ }
290
+ } catch (responseError) {
291
+ // Fall back to default response if custom response fails
292
+ }
293
+ }
294
+
295
+ // Default error handling
296
+ let statusCode = 401
297
+ let message = 'Invalid token'
298
+
299
+ if (error.message === 'Authentication required') {
300
+ message = 'Authentication required'
301
+ } else if (error.message === 'Invalid API key') {
302
+ message = 'Invalid API key'
303
+ } else if (error.message === 'JWT verification not configured') {
304
+ message = 'JWT verification not configured'
305
+ } else if (error instanceof errors.JWTExpired) {
306
+ message = 'Token expired'
307
+ } else if (error instanceof errors.JWTInvalid) {
308
+ message = 'Invalid token format'
309
+ } else if (error instanceof errors.JWKSNoMatchingKey) {
310
+ message = 'Token signature verification failed'
311
+ } else if (error.message.includes('audience')) {
312
+ message = 'Invalid token audience'
313
+ } else if (error.message.includes('issuer')) {
314
+ message = 'Invalid token issuer'
315
+ }
316
+
317
+ return new Response(JSON.stringify({error: message}), {
318
+ status: statusCode,
319
+ headers: {'Content-Type': 'application/json'},
320
+ })
321
+ }
322
+
323
+ /**
324
+ * Extracts JWT token from Authorization header
325
+ * @param {Request} req - Request object
326
+ * @returns {string|null} JWT token or null if not found
327
+ */
328
+ function extractTokenFromHeader(req) {
329
+ const authorization = req.headers.get('authorization')
330
+
331
+ if (!authorization) {
332
+ return null
333
+ }
334
+
335
+ const parts = authorization.split(' ')
336
+ if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
337
+ return null
338
+ }
339
+
340
+ return parts[1]
341
+ }
342
+
343
+ /**
344
+ * Creates a simple API key authentication middleware
345
+ * @param {Object} options - API key configuration
346
+ * @param {string|Array<string>|Function} options.keys - Valid API keys or validation function
347
+ * @param {string} options.header - Header name for API key (default: 'x-api-key')
348
+ * @param {Function} options.getKey - Custom key extraction function
349
+ * @returns {Function} Middleware function
350
+ */
351
+ function createAPIKeyAuth(options = {}) {
352
+ const {keys, header = 'x-api-key', getKey} = options
353
+
354
+ if (!keys) {
355
+ throw new Error('API key middleware requires keys configuration')
356
+ }
357
+
358
+ const validateKey =
359
+ typeof keys === 'function'
360
+ ? keys
361
+ : (key) => (Array.isArray(keys) ? keys.includes(key) : keys === key)
362
+
363
+ return async function apiKeyAuthMiddleware(req, next) {
364
+ try {
365
+ // Extract API key
366
+ const apiKey = getKey ? getKey(req) : req.headers.get(header)
367
+
368
+ if (!apiKey) {
369
+ return new Response(JSON.stringify({error: 'API key required'}), {
370
+ status: 401,
371
+ headers: {'Content-Type': 'application/json'},
372
+ })
373
+ }
374
+
375
+ // Validate API key
376
+ const isValid = await validateKey(apiKey, req)
377
+
378
+ if (!isValid) {
379
+ return new Response(JSON.stringify({error: 'Invalid API key'}), {
380
+ status: 401,
381
+ headers: {'Content-Type': 'application/json'},
382
+ })
383
+ }
384
+
385
+ // Add API key info to context
386
+ req.ctx = req.ctx || {}
387
+ req.ctx.apiKey = apiKey
388
+
389
+ return next()
390
+ } catch (error) {
391
+ return new Response(JSON.stringify({error: 'Authentication failed'}), {
392
+ status: 500,
393
+ headers: {'Content-Type': 'application/json'},
394
+ })
395
+ }
396
+ }
397
+ }
398
+
399
+ module.exports = {
400
+ createJWTAuth,
401
+ createAPIKeyAuth,
402
+ extractTokenFromHeader,
403
+ extractToken,
404
+ validateApiKeyInternal,
405
+ handleAuthError,
406
+ }
@@ -0,0 +1,310 @@
1
+ const pino = require('pino')
2
+ const crypto = require('crypto')
3
+
4
+ /**
5
+ * Creates a logging middleware using Pino logger
6
+ * @param {Object} options - Logger configuration options
7
+ * @param {Object} options.pinoOptions - Pino logger options
8
+ * @param {Function} options.serializers - Custom serializers for request/response
9
+ * @param {boolean} options.logBody - Whether to log request/response bodies
10
+ * @param {Array<string>} options.excludePaths - Paths to exclude from logging
11
+ * @param {Object} options.logger - Injected logger instance
12
+ * @param {string} options.level - Log level (alternative to pinoOptions.level)
13
+ * @param {string} options.requestIdHeader - Header name to read request ID from
14
+ * @param {Function} options.generateRequestId - Custom request ID generator function
15
+ * @returns {Function} Middleware function
16
+ */
17
+ function createLogger(options = {}) {
18
+ const {
19
+ pinoOptions = {},
20
+ logBody = false,
21
+ excludePaths = ['/health', '/ping', '/favicon.ico'],
22
+ logger: injectedLogger,
23
+ level,
24
+ serializers,
25
+ requestIdHeader,
26
+ generateRequestId,
27
+ } = options
28
+
29
+ // Build final pino options with proper precedence
30
+ const finalPinoOptions = {
31
+ level: level || pinoOptions.level || process.env.LOG_LEVEL || 'info',
32
+ timestamp: pino.stdTimeFunctions.isoTime,
33
+ formatters: {
34
+ level: (label) => ({level: label.toUpperCase()}),
35
+ },
36
+ serializers: {
37
+ req: (req) => ({
38
+ method: req.method,
39
+ url: req.url,
40
+ headers: req.headers,
41
+ ...(logBody && req.body ? {body: req.body} : {}),
42
+ }),
43
+ // Default res serializer removed to allow logResponse to handle it fully
44
+ err: pino.stdSerializers.err,
45
+ // Merge in custom serializers if provided
46
+ ...(serializers || {}),
47
+ },
48
+ ...pinoOptions,
49
+ }
50
+
51
+ // Use injected logger if provided (for tests), otherwise create a new one
52
+ const logger = injectedLogger || pino(finalPinoOptions)
53
+
54
+ return function loggerMiddleware(req, next) {
55
+ const startTime = process.hrtime.bigint()
56
+ const url = new URL(req.url)
57
+
58
+ if (excludePaths.some((path) => url.pathname.startsWith(path))) {
59
+ return next()
60
+ }
61
+
62
+ // Generate or extract request ID
63
+ let requestId
64
+ if (requestIdHeader && req.headers.get(requestIdHeader)) {
65
+ requestId = req.headers.get(requestIdHeader)
66
+ } else if (generateRequestId) {
67
+ requestId = generateRequestId()
68
+ } else {
69
+ requestId = crypto.randomUUID()
70
+ }
71
+
72
+ // Add logger and requestId to context and root
73
+ req.ctx = req.ctx || {}
74
+ req.ctx.requestId = requestId
75
+ req.requestId = requestId
76
+
77
+ // Create child logger with request context
78
+ const childLogger = logger.child({
79
+ requestId: requestId,
80
+ method: req.method,
81
+ path: url.pathname,
82
+ })
83
+ req.ctx.log = childLogger
84
+ req.log = childLogger
85
+
86
+ // Check if we should log based on level
87
+ const effectiveLevel =
88
+ level || pinoOptions.level || process.env.LOG_LEVEL || 'info'
89
+ const shouldLogInfo = shouldLog('info', effectiveLevel)
90
+
91
+ // Log request started
92
+ if (shouldLogInfo) {
93
+ const logObj = {
94
+ msg: 'Request started',
95
+ method: req.method,
96
+ url: url.pathname,
97
+ }
98
+
99
+ // Apply custom serializers if provided
100
+ if (serializers && serializers.req) {
101
+ Object.assign(logObj, serializers.req(req))
102
+ } else if (logBody && req.body) {
103
+ // Add body to log if logBody is enabled and no custom serializer
104
+ logObj.body = req.body
105
+ }
106
+
107
+ childLogger.info(logObj)
108
+ }
109
+
110
+ try {
111
+ const result = next()
112
+ if (result instanceof Promise) {
113
+ return result
114
+ .then((response) => {
115
+ logResponse(
116
+ childLogger,
117
+ response,
118
+ startTime,
119
+ req,
120
+ url,
121
+ shouldLogInfo,
122
+ serializers,
123
+ logBody,
124
+ )
125
+ return response
126
+ })
127
+ .catch((error) => {
128
+ logError(childLogger, error, startTime)
129
+ throw error
130
+ })
131
+ } else {
132
+ logResponse(
133
+ childLogger,
134
+ result,
135
+ startTime,
136
+ req,
137
+ url,
138
+ shouldLogInfo,
139
+ serializers,
140
+ logBody,
141
+ )
142
+ return result
143
+ }
144
+ } catch (error) {
145
+ logError(childLogger, error, startTime)
146
+ throw error
147
+ }
148
+ }
149
+ }
150
+
151
+ // Helper function to determine if we should log at a given level
152
+ function shouldLog(logLevel, configuredLevel) {
153
+ const levels = {
154
+ trace: 10,
155
+ debug: 20,
156
+ info: 30,
157
+ warn: 40,
158
+ error: 50,
159
+ fatal: 60,
160
+ }
161
+
162
+ const logLevelNum = levels[logLevel] || 30
163
+ const configuredLevelNum = levels[configuredLevel] || 30
164
+
165
+ return logLevelNum >= configuredLevelNum
166
+ }
167
+
168
+ function logResponse(
169
+ logger,
170
+ response,
171
+ startTime,
172
+ req,
173
+ url,
174
+ shouldLogInfo,
175
+ customSerializers, // serializers from createLogger options
176
+ logBodyOpt, // logBody from createLogger options
177
+ ) {
178
+ if (!shouldLogInfo) return
179
+
180
+ const duration = Number(process.hrtime.bigint() - startTime) / 1000000
181
+
182
+ let responseSize
183
+ if (response) {
184
+ if (response.headers && response.headers.get) {
185
+ const contentLength = response.headers.get('content-length')
186
+ if (contentLength) {
187
+ responseSize = parseInt(contentLength, 10)
188
+ }
189
+ }
190
+
191
+ if (responseSize === undefined) {
192
+ const bodyToMeasure = response.hasOwnProperty('_bodyForLogger')
193
+ ? response._bodyForLogger
194
+ : response.body
195
+ if (bodyToMeasure instanceof ReadableStream) {
196
+ responseSize = undefined
197
+ } else if (typeof bodyToMeasure === 'string') {
198
+ responseSize = Buffer.byteLength(bodyToMeasure, 'utf8')
199
+ } else if (bodyToMeasure instanceof ArrayBuffer) {
200
+ responseSize = bodyToMeasure.byteLength
201
+ } else if (bodyToMeasure instanceof Uint8Array) {
202
+ responseSize = bodyToMeasure.length
203
+ } else if (bodyToMeasure === null || bodyToMeasure === undefined) {
204
+ responseSize = 0
205
+ }
206
+ }
207
+ }
208
+
209
+ const logEntry = {
210
+ msg: 'Request completed',
211
+ method: req.method,
212
+ url: url.pathname,
213
+ status: response && response.status,
214
+ duration: duration,
215
+ }
216
+
217
+ if (responseSize !== undefined) {
218
+ logEntry.responseSize = responseSize
219
+ }
220
+
221
+ // Handle response serialization
222
+ if (customSerializers && customSerializers.res) {
223
+ // Custom serializer is responsible for all response fields it wants to log
224
+ const serializedRes = customSerializers.res(response)
225
+ Object.assign(logEntry, serializedRes)
226
+ } else {
227
+ // No custom res serializer: default handling for headers
228
+ if (
229
+ response &&
230
+ response.headers &&
231
+ typeof response.headers.entries === 'function'
232
+ ) {
233
+ logEntry.headers = Object.fromEntries(response.headers.entries())
234
+ } else if (response && response.headers) {
235
+ logEntry.headers = response.headers
236
+ } else {
237
+ logEntry.headers = {}
238
+ }
239
+ }
240
+
241
+ // If logBodyOpt is true and body wasn't added by a custom serializer (or no custom serializer)
242
+ if (logBodyOpt && response && !logEntry.hasOwnProperty('body')) {
243
+ logEntry.body = response.hasOwnProperty('_bodyForLogger')
244
+ ? response._bodyForLogger
245
+ : response.body
246
+ }
247
+
248
+ logger.info(logEntry)
249
+ }
250
+
251
+ function logError(logger, error, startTime) {
252
+ const duration = Number(process.hrtime.bigint() - startTime) / 1000000
253
+ logger.error({
254
+ msg: 'Request failed',
255
+ error: error && error.message,
256
+ duration: duration,
257
+ })
258
+ }
259
+
260
+ /**
261
+ * Simple request logger for development
262
+ * @returns {Function} Middleware function
263
+ */
264
+ function simpleLogger() {
265
+ return function simpleLoggerMiddleware(req, next) {
266
+ const startTime = Date.now()
267
+ const method = req.method
268
+ const url = new URL(req.url)
269
+ const pathname = url.pathname
270
+
271
+ console.log(`→ ${method} ${pathname}`)
272
+
273
+ try {
274
+ const result = next()
275
+
276
+ if (result instanceof Promise) {
277
+ return result
278
+ .then((response) => {
279
+ const duration = Date.now() - startTime
280
+ console.log(
281
+ `← ${method} ${pathname} ${response.status} (${duration}ms)`,
282
+ )
283
+ return response
284
+ })
285
+ .catch((error) => {
286
+ const duration = Date.now() - startTime
287
+ console.log(
288
+ `✗ ${method} ${pathname} ERROR (${duration}ms): ${error.message}`,
289
+ )
290
+ throw error
291
+ })
292
+ } else {
293
+ const duration = Date.now() - startTime
294
+ console.log(`← ${method} ${pathname} ${result.status} (${duration}ms)`)
295
+ return result
296
+ }
297
+ } catch (error) {
298
+ const duration = Date.now() - startTime
299
+ console.log(
300
+ `✗ ${method} ${pathname} ERROR (${duration}ms): ${error.message}`,
301
+ )
302
+ throw error
303
+ }
304
+ }
305
+ }
306
+
307
+ module.exports = {
308
+ createLogger,
309
+ simpleLogger,
310
+ }