expediate 1.0.1 → 1.0.2

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/src/jwt-auth.ts DELETED
@@ -1,861 +0,0 @@
1
- /* Copyright 2021 Fabien Bavent
2
- *
3
- * Permission is hereby granted, free of charge, to any person obtaining a
4
- * copy of this software and associated documentation files (the "Software"),
5
- * to deal in the Software without restriction, including without limitation
6
- * the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
- * and/or sell copies of the Software, and to permit persons to whom the
8
- * Software is furnished to do so, subject to the following conditions:
9
- *
10
- * The above copyright notice and this permission notice shall be included
11
- * in all copies or substantial portions of the Software.
12
- *
13
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
14
- * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
- * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
- * DEALINGS IN THE SOFTWARE.
20
- */
21
- /**
22
- * jwt-auth.ts
23
- *
24
- * JWT authentication plugin for the Expediate router.
25
- *
26
- * Provides:
27
- * - Stateless access tokens (HS256 / HS384 / HS512 HMAC-signed JWTs).
28
- * - Opaque refresh tokens with server-side storage and automatic rotation.
29
- * - Route handlers for login, token refresh, and logout.
30
- * - Middleware for token validation, authorisation, role checks, and
31
- * permission checks.
32
- *
33
- * Security notes:
34
- * - Passwords are hashed with SHA-256 for demonstration purposes only.
35
- * Replace with bcrypt / argon2 in production.
36
- * - The default secrets are placeholders — always override them in production.
37
- * - Refresh token storage defaults to an in-process Map; replace with a
38
- * persistent store (Redis, database) for multi-instance deployments.
39
- */
40
-
41
- import crypto from 'crypto';
42
- import type { RouterRequest, RouterResponse, Middleware } from './router.js';
43
-
44
- // ---------------------------------------------------------------------------
45
- // Types
46
- // ---------------------------------------------------------------------------
47
-
48
- /** A user record as stored in (or returned by) the user database. */
49
- export interface UserRecord {
50
- /** Stable unique identifier (used as JWT `sub` claim when present). */
51
- id?: string;
52
- /** Login username. */
53
- username: string;
54
- /**
55
- * SHA-256 hex digest of the user's password.
56
- * Replace with a bcrypt/argon2 hash in production.
57
- */
58
- passwordHash: string;
59
- /** Role labels assigned to this user (e.g. `'admin'`, `'editor'`). */
60
- roles?: string[];
61
- /**
62
- * Fine-grained permission strings assigned to this user
63
- * (e.g. `'read'`, `'write'`, `'delete'`).
64
- */
65
- permissions?: string[];
66
- /** Any additional fields the application wants to carry. */
67
- [key: string]: unknown;
68
- }
69
-
70
- /**
71
- * The decoded JWT access-token payload attached to `req.user` after
72
- * successful authentication.
73
- */
74
- export interface TokenPayload {
75
- /** JWT subject — typically the user's stable ID. */
76
- sub: string;
77
- /** Username extracted from the user record. */
78
- username: string;
79
- /** Issuer claim, set to `config.issuer`. */
80
- iss: string;
81
- /** Issued-at timestamp (Unix seconds). */
82
- iat: number;
83
- /** Expiration timestamp (Unix seconds). */
84
- exp: number;
85
- /** Roles copied from the user record. */
86
- roles?: string[];
87
- /** Permissions copied from the user record. */
88
- permissions?: string[];
89
- /** Any additional claims produced by `config.payload`. */
90
- [key: string]: unknown;
91
- }
92
-
93
- /** Internal metadata stored alongside each active refresh token. */
94
- interface RefreshTokenData {
95
- /** Username the refresh token was issued to. */
96
- username: string;
97
- /** Unix ms timestamp of issuance. */
98
- issuedAt: number;
99
- /** Unix ms timestamp after which the token must be rejected. */
100
- expiresAt: number;
101
- }
102
-
103
- /**
104
- * Minimal interface for the refresh-token store.
105
- * Any object implementing these four methods is accepted (Map, Redis client
106
- * adapter, database wrapper, etc.).
107
- */
108
- export interface TokenStore {
109
- set(key: string, value: RefreshTokenData): void;
110
- get(key: string): RefreshTokenData | undefined;
111
- delete(key: string): void;
112
- has(key: string): boolean;
113
- }
114
-
115
- /**
116
- * Supported HMAC-SHA signing algorithms for JWT.
117
- * RS*, ES*, and PS* families are not yet implemented.
118
- */
119
- export type JwtAlgorithm = 'HS256' | 'HS384' | 'HS512';
120
-
121
- /**
122
- * Full configuration object for {@link createJwtPlugin}.
123
- * All fields have defaults; override only what you need.
124
- */
125
- export interface JwtConfig {
126
- /** HMAC secret used to sign access tokens. **Change in production.** */
127
- accessTokenSecret: string;
128
- /** HMAC secret used to sign refresh tokens (currently unused — refresh *
129
- * tokens are opaque random strings, not JWTs). Reserved for future use. */
130
- refreshTokenSecret: string;
131
- /** Access token lifetime in **seconds**. Defaults to 15 minutes. */
132
- accessTokenExpiry: number;
133
- /** Refresh token lifetime in **seconds**. Defaults to 7 days. */
134
- refreshTokenExpiry: number;
135
- /** Value placed in the JWT `iss` claim. */
136
- issuer: string;
137
- /**
138
- * When `true`, the `authenticate` middleware rejects tokens whose `iss`
139
- * claim does not match `config.issuer`.
140
- * Defaults to `false` (issuer not checked).
141
- */
142
- checkIssuer: boolean;
143
- /** JWT signing algorithm. Defaults to `'HS256'`. */
144
- alg: JwtAlgorithm;
145
- /**
146
- * Extract the login username from a user record.
147
- * Defaults to `(user) => user.username`.
148
- */
149
- username: (user: UserRecord) => string;
150
- /**
151
- * Fetch a user record by username.
152
- * Return `undefined` (or any falsy value) when the user does not exist.
153
- */
154
- fetchUser: (username: string) => UserRecord | undefined;
155
- /**
156
- * Return `true` when the supplied plain-text `password` is valid for
157
- * `user`, `false` otherwise.
158
- *
159
- * The default implementation compares SHA-256 hashes; replace with a
160
- * timing-safe bcrypt/argon2 check in production.
161
- */
162
- isPasswordValid: (user: UserRecord, password: string) => boolean;
163
- /**
164
- * Build the JWT payload for a user.
165
- * The `iss`, `iat`, `exp`, and `sub` claims are added automatically.
166
- * Returning a partial object is fine — the plugin merges the rest.
167
- */
168
- payload: (user: UserRecord) => Partial<TokenPayload>;
169
- /**
170
- * Active refresh-token store.
171
- * Defaults to an in-process `Map` (lost on restart; not suitable for
172
- * multi-instance deployments).
173
- */
174
- refreshTokenStore: TokenStore;
175
- }
176
-
177
- /** Result returned by internal authentication / token-issue functions. */
178
- type AuthResult =
179
- | { success: true; accessToken: string; refreshToken: string;
180
- expiresIn: number; tokenType: 'Bearer' }
181
- | { success: false; error: string };
182
-
183
- /** Result returned by {@link verifyToken}. */
184
- type VerifyResult =
185
- | { valid: true; payload: TokenPayload }
186
- | { valid: false; error: string };
187
-
188
- // ---------------------------------------------------------------------------
189
- // Sample user database (replace or extend for real applications)
190
- // ---------------------------------------------------------------------------
191
-
192
- /**
193
- * Hash a plain-text password with SHA-256 and return the hex digest.
194
- *
195
- * > **⚠ Warning:** SHA-256 is fast and therefore unsuitable for password
196
- * > hashing in production. Use bcrypt or argon2 instead.
197
- *
198
- * @param password - The plain-text password to hash.
199
- * @returns A 64-character lowercase hex string.
200
- */
201
- export function hashPassword(password: string): string {
202
- return crypto.createHash('sha256').update(password).digest('hex');
203
- }
204
-
205
- /**
206
- * Default in-memory user database used when no custom `fetchUser` is
207
- * provided. Contains three demo accounts: alice (admin), bob (editor),
208
- * charlie (viewer).
209
- *
210
- * Replace or ignore this map entirely when you supply your own `fetchUser`.
211
- */
212
- export const userDatabase = new Map<string, UserRecord>([
213
- ['alice', {
214
- id: 'usr_001',
215
- username: 'alice',
216
- passwordHash: hashPassword('password123'),
217
- roles: ['admin', 'editor'],
218
- permissions: ['read', 'write', 'delete', 'manage_users'],
219
- }],
220
- ['bob', {
221
- id: 'usr_002',
222
- username: 'bob',
223
- passwordHash: hashPassword('secret456'),
224
- roles: ['editor'],
225
- permissions: ['read', 'write'],
226
- }],
227
- ['charlie', {
228
- id: 'usr_003',
229
- username: 'charlie',
230
- passwordHash: hashPassword('pass789'),
231
- roles: ['viewer'],
232
- permissions: ['read'],
233
- }],
234
- ]);
235
-
236
- // ---------------------------------------------------------------------------
237
- // Default configuration
238
- // ---------------------------------------------------------------------------
239
-
240
- const DEFAULT_CONFIG: JwtConfig = {
241
- accessTokenSecret: 'access-secret-change-in-production',
242
- refreshTokenSecret: 'refresh-secret-change-in-production',
243
- accessTokenExpiry: 15 * 60, // 15 minutes
244
- refreshTokenExpiry: 7 * 24 * 3600, // 7 days
245
- issuer: 'jwt-auth',
246
- checkIssuer: false,
247
- alg: 'HS256',
248
-
249
- username: (user) => user.username,
250
- fetchUser: (username) => userDatabase.get(username),
251
-
252
- // BUG FIX: the original callback was named `checkPassword` and returned
253
- // `true` when the password was WRONG (inverted logic). Renamed to
254
- // `isPasswordValid` and inverted so it returns `true` on a match.
255
- isPasswordValid: (user, password) => user.passwordHash === hashPassword(password),
256
-
257
- payload: (user) => ({
258
- sub: user.id,
259
- username: user.username,
260
- roles: user.roles,
261
- permissions: user.permissions,
262
- }),
263
-
264
- refreshTokenStore: new Map<string, RefreshTokenData>(),
265
- };
266
-
267
- // ---------------------------------------------------------------------------
268
- // JWT utilities — manual Base64URL implementation
269
- // ---------------------------------------------------------------------------
270
-
271
- /**
272
- * Encode an arbitrary object as a Base64URL-encoded JSON string, suitable
273
- * for use as a JWT header or payload segment.
274
- *
275
- * @param data - Any JSON-serialisable value.
276
- * @returns A Base64URL string with no padding characters.
277
- */
278
- function base64UrlEncode(data: object): string {
279
- return Buffer.from(JSON.stringify(data))
280
- .toString('base64')
281
- .replace(/=/g, '')
282
- .replace(/\+/g, '-')
283
- .replace(/\//g, '_');
284
- }
285
-
286
- /**
287
- * Decode a Base64URL-encoded JWT segment and parse it as JSON.
288
- *
289
- * @param str - A Base64URL string (padding optional).
290
- * @returns The parsed JSON value.
291
- * @throws When the string is not valid Base64URL JSON.
292
- */
293
- function base64UrlDecode(str: string): unknown {
294
- const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
295
- return JSON.parse(
296
- Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
297
- );
298
- }
299
-
300
- /**
301
- * Compute the Base64URL-encoded HMAC signature for a JWT.
302
- *
303
- * Receives the **already-encoded** header and payload strings (i.e. the
304
- * first two dot-separated segments of the token) and signs the
305
- * `"<header>.<payload>"` string with the given secret.
306
- *
307
- * Currently supports the HS (HMAC-SHA) family: `HS256`, `HS384`, `HS512`.
308
- *
309
- * @param encodedHeader - Base64URL-encoded JWT header string.
310
- * @param encodedPayload - Base64URL-encoded JWT payload string.
311
- * @param secret - HMAC secret key.
312
- * @param alg - Algorithm identifier (must be HS256, HS384, or HS512).
313
- * @returns A Base64URL-encoded signature string.
314
- * @throws {Error} When `alg` is not a supported HS algorithm.
315
- */
316
- function createSignature(
317
- encodedHeader: string,
318
- encodedPayload: string,
319
- secret: string,
320
- alg: JwtAlgorithm,
321
- ): string {
322
- // BUG FIX: the original code called `header.alg` where `header` was already
323
- // a Base64URL *string*, not a decoded object. Accessing `.alg` on a string
324
- // always returns `undefined`, causing every signature to fail. The algorithm
325
- // is now passed explicitly as a parameter instead of being read from the header.
326
- const shaVariant = `sha${alg.substring(2)}`; // 'sha256', 'sha384', 'sha512'
327
- return crypto
328
- .createHmac(shaVariant, secret)
329
- .update(`${encodedHeader}.${encodedPayload}`)
330
- .digest('base64')
331
- .replace(/=/g, '')
332
- .replace(/\+/g, '-')
333
- .replace(/\//g, '_');
334
- }
335
-
336
- /**
337
- * Sign a payload object and return a compact JWT string.
338
- *
339
- * Automatically adds the `iat` (issued-at) and `exp` (expiration) claims.
340
- * Any claims already present in `payload` are preserved and take precedence
341
- * over `iat`/`exp` (use this to override expiry if needed).
342
- *
343
- * @param payload - JWT payload claims (must be JSON-serialisable).
344
- * @param secret - HMAC secret used to sign the token.
345
- * @param expiresIn - Validity window in **seconds** from the current time.
346
- * @param alg - Signing algorithm. Defaults to `'HS256'`.
347
- * @returns A compact JWT string in the form `header.payload.signature`.
348
- */
349
- function signToken(
350
- payload: Partial<TokenPayload>,
351
- secret: string,
352
- expiresIn: number,
353
- alg: JwtAlgorithm = 'HS256',
354
- ): string {
355
- const now = Math.floor(Date.now() / 1000);
356
- const encodedHeader = base64UrlEncode({ alg, typ: 'JWT' });
357
- const fullPayload = base64UrlEncode({ ...payload, iat: now, exp: now + expiresIn });
358
- const signature = createSignature(encodedHeader, fullPayload, secret, alg);
359
- return `${encodedHeader}.${fullPayload}.${signature}`;
360
- }
361
-
362
- /**
363
- * Verify a compact JWT string and return its decoded payload on success.
364
- *
365
- * Performs the following checks in order:
366
- * 1. Structural validity (exactly three dot-separated segments).
367
- * 2. Algorithm consistency (header `alg` matches the expected `alg`).
368
- * 3. Signature integrity (timing-safe HMAC comparison).
369
- * 4. Expiration (`exp` claim is in the future).
370
- *
371
- * All errors are returned as `{ valid: false, error }` — no exception is
372
- * thrown to the caller.
373
- *
374
- * @param token - The compact JWT string to verify.
375
- * @param secret - HMAC secret that was used to sign the token.
376
- * @param alg - Expected signing algorithm.
377
- * @returns A {@link VerifyResult} discriminated union.
378
- */
379
- function verifyToken(token: string, secret: string, alg: JwtAlgorithm): VerifyResult {
380
- try {
381
- const parts = token.split('.');
382
- if (parts.length !== 3)
383
- return { valid: false, error: 'Invalid token format' };
384
-
385
- const [encodedHeader, encodedPayload, signature] = parts;
386
-
387
- // BUG FIX: the original code accessed `header.alg` where `header` is a
388
- // Base64URL *string*. `.alg` on a string is always `undefined`, so the
389
- // algorithm check always failed and every token was rejected. The header
390
- // must be decoded first.
391
- const decodedHeader = base64UrlDecode(encodedHeader) as { alg?: string; typ?: string };
392
- if (decodedHeader.alg !== alg)
393
- return { valid: false, error: 'Unauthorised signing algorithm' };
394
-
395
- const expectedSig = createSignature(encodedHeader, encodedPayload, secret, alg);
396
-
397
- // BUG FIX: `timingSafeEqual` requires both Buffers to have the same
398
- // length. When a forged token has a signature of a different length,
399
- // Node throws a RangeError instead of returning `false`. Guard against
400
- // this by checking lengths before calling timingSafeEqual.
401
- const sigBuf = Buffer.from(signature);
402
- const expectedBuf = Buffer.from(expectedSig);
403
- if (
404
- sigBuf.length !== expectedBuf.length ||
405
- !crypto.timingSafeEqual(sigBuf, expectedBuf)
406
- ) {
407
- return { valid: false, error: 'Invalid signature' };
408
- }
409
-
410
- const payload = base64UrlDecode(encodedPayload) as TokenPayload;
411
- const now = Math.floor(Date.now() / 1000);
412
-
413
- if (payload.exp && payload.exp < now)
414
- return { valid: false, error: 'Token expired' };
415
-
416
- return { valid: true, payload };
417
- } catch {
418
- return { valid: false, error: 'Malformed token' };
419
- }
420
- }
421
-
422
- /**
423
- * Generate a cryptographically secure opaque refresh token.
424
- *
425
- * The token is 128 hex characters (64 random bytes), providing 512 bits of
426
- * entropy — far beyond any brute-force threat.
427
- *
428
- * @returns A 128-character lowercase hex string.
429
- */
430
- function generateRefreshToken(): string {
431
- return crypto.randomBytes(64).toString('hex');
432
- }
433
-
434
- // ---------------------------------------------------------------------------
435
- // Business logic
436
- // ---------------------------------------------------------------------------
437
-
438
- /**
439
- * Authenticate a user by username and password and, on success, issue a new
440
- * access + refresh token pair.
441
- *
442
- * @param username - The username supplied by the client.
443
- * @param password - The plain-text password supplied by the client.
444
- * @param config - Resolved plugin configuration.
445
- * @returns An {@link AuthResult} discriminated union.
446
- */
447
- function authenticateUser(username: string, password: string, config: JwtConfig): AuthResult {
448
- const user = config.fetchUser(username);
449
- if (!user) return { success: false, error: 'User not found' };
450
-
451
- // BUG FIX: the original used `checkPassword` which returned `true` when
452
- // the password was WRONG (negated logic). `isPasswordValid` returns `true`
453
- // when the password is correct.
454
- if (!config.isPasswordValid(user, password))
455
- return { success: false, error: 'Incorrect password' };
456
-
457
- return issueTokenPair(user, config);
458
- }
459
-
460
- /**
461
- * Build and store a new access + refresh token pair for the given user.
462
- *
463
- * The refresh token is stored in `config.refreshTokenStore` with its
464
- * expiration timestamp so it can be validated on subsequent renewal requests.
465
- *
466
- * @param user - The authenticated user record.
467
- * @param config - Resolved plugin configuration.
468
- * @returns An {@link AuthResult} with `success: true`.
469
- */
470
- function issueTokenPair(user: UserRecord, config: JwtConfig): AuthResult {
471
- const username = config.username(user);
472
- const claims = config.payload(user);
473
-
474
- // Inject standard claims; caller-supplied claims take precedence.
475
- const fullClaims: Partial<TokenPayload> = {
476
- sub: username, // fallback subject — overridden by payload() if it sets sub
477
- ...claims,
478
- iss: config.issuer,
479
- };
480
-
481
- const accessToken = signToken(fullClaims, config.accessTokenSecret, config.accessTokenExpiry, config.alg);
482
- const refreshToken = generateRefreshToken();
483
-
484
- config.refreshTokenStore.set(refreshToken, {
485
- username,
486
- issuedAt: Date.now(),
487
- expiresAt: Date.now() + config.refreshTokenExpiry * 1000,
488
- });
489
-
490
- return {
491
- success: true,
492
- accessToken,
493
- refreshToken,
494
- expiresIn: config.accessTokenExpiry,
495
- tokenType: 'Bearer',
496
- };
497
- }
498
-
499
- /**
500
- * Renew an access token using a valid refresh token.
501
- *
502
- * Implements **refresh token rotation**: the presented refresh token is
503
- * always invalidated and a brand-new pair is issued on success. This means
504
- * a stolen refresh token can only be used once before it is invalidated by
505
- * the legitimate holder's next renewal.
506
- *
507
- * @param username - The username from the renewal request body.
508
- * @param refreshToken - The opaque refresh token string.
509
- * @param config - Resolved plugin configuration.
510
- * @returns An {@link AuthResult} discriminated union.
511
- */
512
- function renewAccessToken(username: string, refreshToken: string, config: JwtConfig): AuthResult {
513
- const tokenData = config.refreshTokenStore.get(refreshToken);
514
-
515
- // Verify the token exists and belongs to the claimed user.
516
- if (!tokenData || tokenData.username !== username)
517
- return { success: false, error: 'Invalid or revoked refresh token' };
518
-
519
- if (Date.now() > tokenData.expiresAt) {
520
- config.refreshTokenStore.delete(refreshToken);
521
- return { success: false, error: 'Refresh token expired' };
522
- }
523
-
524
- const user = config.fetchUser(tokenData.username);
525
- if (!user) {
526
- config.refreshTokenStore.delete(refreshToken);
527
- return { success: false, error: 'User not found' };
528
- }
529
-
530
- // Rotate: invalidate the used token before issuing a new pair.
531
- config.refreshTokenStore.delete(refreshToken);
532
- return issueTokenPair(user, config);
533
- }
534
-
535
- /**
536
- * Revoke a refresh token, preventing it from being used to obtain new access
537
- * tokens. Idempotent — revoking an already-revoked token is not an error.
538
- *
539
- * @param refreshToken - The opaque refresh token string to revoke.
540
- * @param config - Resolved plugin configuration.
541
- * @returns `true` if the token existed and was removed, `false` if it was
542
- * already absent.
543
- */
544
- function revokeRefreshToken(refreshToken: string, config: JwtConfig): boolean {
545
- // BUG FIX: the original function referenced `config` as a free variable but
546
- // `config` only exists inside `createJwtPlugin`. The function was declared
547
- // at module level and crashed with a ReferenceError at runtime. `config` is
548
- // now a required parameter.
549
- const existed = config.refreshTokenStore.has(refreshToken);
550
- config.refreshTokenStore.delete(refreshToken);
551
- return existed;
552
- }
553
-
554
- // ---------------------------------------------------------------------------
555
- // Plugin factory
556
- // ---------------------------------------------------------------------------
557
-
558
- /**
559
- * The object returned by {@link createJwtPlugin}.
560
- *
561
- * Mount the handlers on your router and apply the middleware to protected
562
- * routes:
563
- *
564
- * ```ts
565
- * const auth = createJwtPlugin({ accessTokenSecret: process.env.JWT_SECRET! });
566
- *
567
- * app.post('/auth/login', json(), auth.login);
568
- * app.post('/auth/refresh', json(), auth.refresh);
569
- * app.post('/auth/logout', json(), auth.logout);
570
- *
571
- * app.get('/me', auth.authenticate, auth.authorize, getProfile);
572
- * app.delete('/admin', ...auth.requireRole('admin'), deleteHandler);
573
- * app.put('/posts', ...auth.requirePermission('write'), updateHandler);
574
- * ```
575
- */
576
- export interface JwtPlugin {
577
- /**
578
- * Route handler for `POST /auth/login`.
579
- * Expects a JSON body with `{ username, password }`.
580
- * On success: responds with `{ accessToken, refreshToken, expiresIn, tokenType }`.
581
- */
582
- login: Middleware;
583
- /**
584
- * Route handler for `POST /auth/refresh`.
585
- * Expects a JSON body with `{ username, refreshToken }`.
586
- * On success: responds with a new `{ accessToken, refreshToken, ... }` pair.
587
- */
588
- refresh: Middleware;
589
- /**
590
- * Route handler for `POST /auth/logout`.
591
- * Expects a JSON body with `{ refreshToken }` (optional).
592
- * Always responds with 200; revokes the refresh token if provided.
593
- */
594
- logout: Middleware;
595
- /**
596
- * Middleware that validates the `Authorization: Bearer <token>` header.
597
- * Sets `req.user` to the decoded {@link TokenPayload} on success.
598
- * Calls `next()` silently (without error) when the token is absent or invalid,
599
- * allowing the route to decide how to handle unauthenticated requests.
600
- */
601
- authenticate: Middleware;
602
- /**
603
- * Middleware that rejects unauthenticated requests with 401.
604
- * Should be placed **after** {@link authenticate}:
605
- * `router.get('/me', auth.authenticate, auth.authorize, handler)`.
606
- */
607
- authorize: Middleware;
608
- /**
609
- * Middleware factory that requires the authenticated user to have at least
610
- * one of the specified roles. Returns `[authenticate, roleCheck]` so it
611
- * can be spread directly into a route registration:
612
- * `router.get('/admin', ...auth.requireRole('admin'), handler)`.
613
- * Responds with 401 when unauthenticated, 403 when the role is missing.
614
- */
615
- requireRole: (...roles: string[]) => Middleware[];
616
- /**
617
- * Middleware factory that requires the authenticated user to have **all**
618
- * of the specified permissions. Returns `[authenticate, permCheck]`.
619
- * Responds with 401 when unauthenticated, 403 when any permission is missing.
620
- */
621
- requirePermission: (...permissions: string[]) => Middleware[];
622
- }
623
-
624
- /**
625
- * Create a JWT authentication plugin pre-configured with the given options.
626
- *
627
- * All config fields have safe defaults for development. At minimum, set
628
- * `accessTokenSecret` (and `refreshTokenSecret` if you plan to use it) to
629
- * random values in production.
630
- *
631
- * @param userConfig - Partial {@link JwtConfig} overrides.
632
- * @returns A {@link JwtPlugin} object exposing handlers and middleware.
633
- */
634
- export function createJwtPlugin(userConfig: Partial<JwtConfig> = {}): JwtPlugin {
635
- const config: JwtConfig = { ...DEFAULT_CONFIG, ...userConfig };
636
-
637
- // Helper: write a JSON response (our router's res.send() does not add
638
- // Content-Type automatically, so we set it manually).
639
- function sendJson(res: RouterResponse, status: number, data: object): void {
640
- const body = JSON.stringify(data);
641
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
642
- res.status(status).send(body);
643
- }
644
-
645
- // ── POST /auth/login ────────────────────────────────────────────────────
646
-
647
- /**
648
- * Login handler. Reads `{ username, password }` from `req.body` (requires
649
- * a JSON body-parsing middleware such as `json()` to run first).
650
- */
651
- const login: Middleware = (req: RouterRequest, res: RouterResponse): void => {
652
- const { username, password } = (req as any).body ?? {};
653
-
654
- if (!username || !password) {
655
- sendJson(res, 400, { error: "Fields 'username' and 'password' are required" });
656
- return;
657
- }
658
-
659
- const result = authenticateUser(username, password, config);
660
-
661
- if (!result.success) {
662
- sendJson(res, 401, { error: result.error });
663
- return;
664
- }
665
-
666
- sendJson(res, 200, {
667
- message: 'Authentication successful',
668
- accessToken: result.accessToken,
669
- refreshToken: result.refreshToken,
670
- expiresIn: result.expiresIn,
671
- tokenType: result.tokenType,
672
- });
673
- };
674
-
675
- // ── POST /auth/refresh ──────────────────────────────────────────────────
676
-
677
- /**
678
- * Token-renewal handler. Reads `{ username, refreshToken }` from
679
- * `req.body`.
680
- *
681
- * BUG FIX: the original accepted a renewal request without `username`,
682
- * allowing `renewAccessToken(undefined, token, config)` to be called.
683
- * Because `tokenData.username !== undefined` is always `true` for any real
684
- * token, any holder of a refresh token could silently impersonate its owner.
685
- * `username` is now validated as a required field.
686
- */
687
- const refresh: Middleware = (req: RouterRequest, res: RouterResponse): void => {
688
- const { username, refreshToken } = (req as any).body ?? {};
689
-
690
- if (!username || !refreshToken) {
691
- sendJson(res, 400, { error: "Fields 'username' and 'refreshToken' are required" });
692
- return;
693
- }
694
-
695
- const result = renewAccessToken(username, refreshToken, config);
696
-
697
- if (!result.success) {
698
- sendJson(res, 401, { error: result.error });
699
- return;
700
- }
701
-
702
- sendJson(res, 200, {
703
- message: 'Token renewed successfully',
704
- accessToken: result.accessToken,
705
- refreshToken: result.refreshToken,
706
- expiresIn: result.expiresIn,
707
- tokenType: result.tokenType,
708
- });
709
- };
710
-
711
- // ── POST /auth/logout ───────────────────────────────────────────────────
712
-
713
- /**
714
- * Logout handler. Optionally reads `{ refreshToken }` from `req.body` and
715
- * revokes it. Always responds with 200 regardless of whether a token was
716
- * provided (to avoid leaking information about token existence).
717
- */
718
- const logout: Middleware = (req: RouterRequest, res: RouterResponse): void => {
719
- const { refreshToken } = (req as any).body ?? {};
720
-
721
- if (refreshToken) {
722
- // BUG FIX: the original called `revokeRefreshToken(refreshToken)` without
723
- // passing `config`, which caused a ReferenceError because `config` is a
724
- // local variable inside `createJwtPlugin`.
725
- revokeRefreshToken(refreshToken, config);
726
- }
727
-
728
- sendJson(res, 200, { message: 'Logged out successfully' });
729
- };
730
-
731
- // ── authenticate middleware ─────────────────────────────────────────────
732
-
733
- /**
734
- * Validate the `Authorization: Bearer <token>` header and populate
735
- * `req.user` with the decoded payload.
736
- *
737
- * Designed to be **non-blocking**: missing or invalid tokens cause `next()`
738
- * to be called without error, deferring the authentication decision to the
739
- * next middleware (typically {@link authorize} or a custom guard).
740
- *
741
- * `req.user` is explicitly cleared at the start of each invocation to
742
- * prevent stale data from leaking across requests in unusual server setups.
743
- */
744
- const authenticate: Middleware = (req: RouterRequest, res: RouterResponse, next: () => void): void => {
745
- // Always clear any previously set user to prevent cross-request contamination.
746
- delete (req as any).user;
747
-
748
- const authHeader = req.headers['authorization'] as string | undefined;
749
- if (!authHeader?.startsWith('Bearer ')) return next();
750
-
751
- const token = authHeader.slice(7);
752
- const result = verifyToken(token, config.accessTokenSecret, config.alg);
753
- if (!result.valid) return next();
754
-
755
- // BUG FIX: the original compared `result.payload` (an object) to
756
- // `config.issuer` (a string) with `!=`, which is always `true`, causing
757
- // every token to be rejected when `checkIssuer` was enabled.
758
- // The correct check reads the `iss` claim from the decoded payload.
759
- if (config.checkIssuer && result.payload.iss !== config.issuer) return next();
760
-
761
- (req as any).user = result.payload;
762
- next();
763
- };
764
-
765
- // ── authorize middleware ────────────────────────────────────────────────
766
-
767
- /**
768
- * Reject the request with 401 when `req.user` is not set.
769
- * Always place this **after** {@link authenticate}.
770
- */
771
- const authorize: Middleware = (req: RouterRequest, res: RouterResponse, next: () => void): void => {
772
- if (!(req as any).user) {
773
- sendJson(res, 401, { error: 'Authentication required' });
774
- return;
775
- }
776
- next();
777
- };
778
-
779
- // ── requireRole factory ─────────────────────────────────────────────────
780
-
781
- /**
782
- * Return a two-element middleware chain `[authenticate, roleCheck]` that
783
- * allows the request to proceed only when the authenticated user holds at
784
- * least one of the specified roles.
785
- *
786
- * Responds with 401 when unauthenticated, 403 when none of the required
787
- * roles are present.
788
- *
789
- * BUG FIX: the original accessed `req.user.roles` without first checking
790
- * that `req.user` exists, throwing a TypeError for unauthenticated requests.
791
- */
792
- function requireRole(...roles: string[]): Middleware[] {
793
- return [
794
- authenticate,
795
- (req: RouterRequest, res: RouterResponse, next: () => void): void => {
796
- const user = (req as any).user as TokenPayload | undefined;
797
- if (!user) {
798
- sendJson(res, 401, { error: 'Authentication required' });
799
- return;
800
- }
801
- const userRoles = user.roles ?? [];
802
- if (!roles.some((r) => userRoles.includes(r))) {
803
- sendJson(res, 403, {
804
- error: `Access denied. Required role(s): ${roles.join(', ')}`,
805
- yourRoles: userRoles,
806
- });
807
- return;
808
- }
809
- next();
810
- },
811
- ];
812
- }
813
-
814
- // ── requirePermission factory ───────────────────────────────────────────
815
-
816
- /**
817
- * Return a two-element middleware chain `[authenticate, permCheck]` that
818
- * allows the request to proceed only when the authenticated user holds
819
- * **all** of the specified permissions.
820
- *
821
- * Responds with 401 when unauthenticated, 403 when any permission is absent.
822
- *
823
- * BUG FIX: same `req.user` undefined-access issue as `requireRole`.
824
- */
825
- function requirePermission(...permissions: string[]): Middleware[] {
826
- return [
827
- authenticate,
828
- (req: RouterRequest, res: RouterResponse, next: () => void): void => {
829
- const user = (req as any).user as TokenPayload | undefined;
830
- if (!user) {
831
- sendJson(res, 401, { error: 'Authentication required' });
832
- return;
833
- }
834
- const userPerms = user.permissions ?? [];
835
- if (!permissions.every((p) => userPerms.includes(p))) {
836
- sendJson(res, 403, {
837
- error: `Insufficient permissions. Required: ${permissions.join(', ')}`,
838
- yourPermissions: userPerms,
839
- });
840
- return;
841
- }
842
- next();
843
- },
844
- ];
845
- }
846
-
847
- return {
848
- login,
849
- refresh,
850
- logout,
851
- authenticate,
852
- authorize,
853
- requireRole,
854
- requirePermission,
855
- };
856
- }
857
-
858
- export default createJwtPlugin;
859
-
860
- // Re-export low-level utilities for testing and advanced use cases.
861
- export { signToken, verifyToken, hashPassword as _hashPassword };