expediate 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +16 -16
  2. package/README.md +417 -30
  3. package/dist/apis.d.ts +138 -21
  4. package/dist/apis.d.ts.map +1 -1
  5. package/dist/apis.js +172 -79
  6. package/dist/apis.js.map +1 -1
  7. package/dist/cjs/apis.js +327 -0
  8. package/dist/cjs/git.js +293 -0
  9. package/dist/cjs/index.js +2583 -0
  10. package/dist/cjs/jwt-auth.js +532 -0
  11. package/dist/cjs/middleware.js +511 -0
  12. package/dist/cjs/mimetypes.json +1 -0
  13. package/dist/cjs/misc.js +787 -0
  14. package/dist/cjs/openapi.js +485 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/router.js +898 -0
  17. package/dist/cjs/static.js +669 -0
  18. package/dist/git.d.ts +71 -8
  19. package/dist/git.d.ts.map +1 -1
  20. package/dist/git.js +127 -72
  21. package/dist/git.js.map +1 -1
  22. package/dist/index.d.ts +17 -13
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -24
  25. package/dist/index.js.map +1 -1
  26. package/dist/jwt-auth.d.ts +147 -57
  27. package/dist/jwt-auth.d.ts.map +1 -1
  28. package/dist/jwt-auth.js +445 -205
  29. package/dist/jwt-auth.js.map +1 -1
  30. package/dist/middleware.d.ts +476 -0
  31. package/dist/middleware.d.ts.map +1 -0
  32. package/dist/middleware.js +647 -0
  33. package/dist/middleware.js.map +1 -0
  34. package/dist/mimetypes.json +1 -1
  35. package/dist/misc.d.ts +153 -12
  36. package/dist/misc.d.ts.map +1 -1
  37. package/dist/misc.js +325 -97
  38. package/dist/misc.js.map +1 -1
  39. package/dist/openapi.d.ts +290 -0
  40. package/dist/openapi.d.ts.map +1 -0
  41. package/dist/openapi.js +481 -0
  42. package/dist/openapi.js.map +1 -0
  43. package/dist/router.d.ts +407 -45
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +665 -137
  46. package/dist/router.js.map +1 -1
  47. package/dist/static.d.ts +1 -1
  48. package/dist/static.d.ts.map +1 -1
  49. package/dist/static.js +93 -86
  50. package/dist/static.js.map +1 -1
  51. package/package.json +21 -4
  52. package/.npmignore +0 -16
package/dist/jwt-auth.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /* Copyright 2021 Fabien Bavent
3
2
  *
4
3
  * Permission is hereby granted, free of charge, to any person obtaining a
@@ -25,30 +24,24 @@
25
24
  * JWT authentication plugin for the Expediate router.
26
25
  *
27
26
  * Provides:
28
- * - Stateless access tokens (HS256 / HS384 / HS512 HMAC-signed JWTs).
29
- * - Opaque refresh tokens with server-side storage and automatic rotation.
27
+ * - Stateless access tokens signed with HS256/HS384/HS512 (shared secret) or
28
+ * RS256/RS384/RS512/ES256/ES384/ES512 (asymmetric PEM key pairs).
29
+ * - Signed JWT refresh tokens with JTI-based server-side revocation.
30
30
  * - Route handlers for login, token refresh, and logout.
31
31
  * - Middleware for token validation, authorisation, role checks, and
32
32
  * permission checks.
33
+ * - A `createMapTokenStore()` factory for in-process token storage.
33
34
  *
34
35
  * Security notes:
35
36
  * - Passwords are hashed with SHA-256 for demonstration purposes only.
36
37
  * Replace with bcrypt / argon2 in production.
37
38
  * - The default secrets are placeholders — always override them in production.
38
- * - Refresh token storage defaults to an in-process Map; replace with a
39
- * persistent store (Redis, database) for multi-instance deployments.
39
+ * - `createMapTokenStore()` is an in-process store; replace with a Redis or
40
+ * database adapter for multi-instance deployments.
41
+ * - Refresh tokens are only issued when `refreshTokenStore` is configured.
42
+ * Absence of a store disables refresh-token support entirely.
40
43
  */
41
- var __importDefault = (this && this.__importDefault) || function (mod) {
42
- return (mod && mod.__esModule) ? mod : { "default": mod };
43
- };
44
- Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.userDatabase = void 0;
46
- exports.hashPassword = hashPassword;
47
- exports._hashPassword = hashPassword;
48
- exports.createJwtPlugin = createJwtPlugin;
49
- exports.signToken = signToken;
50
- exports.verifyToken = verifyToken;
51
- const crypto_1 = __importDefault(require("crypto"));
44
+ import crypto from 'crypto';
52
45
  // ---------------------------------------------------------------------------
53
46
  // Sample user database (replace or extend for real applications)
54
47
  // ---------------------------------------------------------------------------
@@ -61,8 +54,8 @@ const crypto_1 = __importDefault(require("crypto"));
61
54
  * @param password - The plain-text password to hash.
62
55
  * @returns A 64-character lowercase hex string.
63
56
  */
64
- function hashPassword(password) {
65
- return crypto_1.default.createHash('sha256').update(password).digest('hex');
57
+ export function hashPassword(password) {
58
+ return crypto.createHash('sha256').update(password).digest('hex');
66
59
  }
67
60
  /**
68
61
  * Default in-memory user database used when no custom `fetchUser` is
@@ -71,7 +64,7 @@ function hashPassword(password) {
71
64
  *
72
65
  * Replace or ignore this map entirely when you supply your own `fetchUser`.
73
66
  */
74
- exports.userDatabase = new Map([
67
+ export const userDatabase = new Map([
75
68
  ['alice', {
76
69
  id: 'usr_001',
77
70
  username: 'alice',
@@ -95,8 +88,50 @@ exports.userDatabase = new Map([
95
88
  }],
96
89
  ]);
97
90
  // ---------------------------------------------------------------------------
91
+ // Token store factory
92
+ // ---------------------------------------------------------------------------
93
+ /**
94
+ * Create an in-process {@link TokenStore} backed by a `Map`.
95
+ *
96
+ * Expired records are cleaned up lazily on `get()` — no background timer is
97
+ * needed. This store is **not** suitable for multi-instance deployments
98
+ * because it is local to the current Node.js process. Replace with a Redis
99
+ * or database adapter for production use.
100
+ *
101
+ * @returns A new {@link TokenStore} instance.
102
+ */
103
+ export function createMapTokenStore() {
104
+ const map = new Map();
105
+ return {
106
+ set(jti, record) {
107
+ map.set(jti, record);
108
+ },
109
+ get(jti) {
110
+ const record = map.get(jti);
111
+ if (!record)
112
+ return undefined;
113
+ // Lazy expiry — clean up the record if it has passed its expiry time.
114
+ if (Date.now() > record.expiresAt) {
115
+ map.delete(jti);
116
+ return undefined;
117
+ }
118
+ return record;
119
+ },
120
+ delete(jti) {
121
+ map.delete(jti);
122
+ },
123
+ deleteBySubject(sub) {
124
+ for (const [jti, record] of map) {
125
+ if (record.sub === sub)
126
+ map.delete(jti);
127
+ }
128
+ },
129
+ };
130
+ }
131
+ // ---------------------------------------------------------------------------
98
132
  // Default configuration
99
133
  // ---------------------------------------------------------------------------
134
+ /** Default configuration values (refreshTokenStore absent — refresh disabled). */
100
135
  const DEFAULT_CONFIG = {
101
136
  accessTokenSecret: 'access-secret-change-in-production',
102
137
  refreshTokenSecret: 'refresh-secret-change-in-production',
@@ -106,21 +141,145 @@ const DEFAULT_CONFIG = {
106
141
  checkIssuer: false,
107
142
  alg: 'HS256',
108
143
  username: (user) => user.username,
109
- fetchUser: (username) => exports.userDatabase.get(username),
110
- // BUG FIX: the original callback was named `checkPassword` and returned
111
- // `true` when the password was WRONG (inverted logic). Renamed to
112
- // `isPasswordValid` and inverted so it returns `true` on a match.
144
+ fetchUser: (sub) => userDatabase.get(sub),
113
145
  isPasswordValid: (user, password) => user.passwordHash === hashPassword(password),
114
146
  payload: (user) => ({
115
- sub: user.id,
116
- username: user.username,
147
+ sub: user.username,
117
148
  roles: user.roles,
118
149
  permissions: user.permissions,
119
150
  }),
120
- refreshTokenStore: new Map(),
121
151
  };
122
152
  // ---------------------------------------------------------------------------
123
- // JWT utilities — manual Base64URL implementation
153
+ // Algorithm helpers
154
+ // ---------------------------------------------------------------------------
155
+ /** True for HMAC-based algorithms (HS256 / HS384 / HS512). */
156
+ function isHmac(alg) { return alg[0] === 'H'; }
157
+ /** True for ECDSA-based algorithms (ES256 / ES384 / ES512). */
158
+ function isEc(alg) { return alg[0] === 'E'; }
159
+ /**
160
+ * Return the Node.js crypto hash algorithm string for a JwtAlgorithm.
161
+ * e.g. 'RS256' → 'SHA256', 'ES384' → 'SHA384'.
162
+ */
163
+ function nodeHashAlg(alg) { return `SHA${alg.slice(2)}`; }
164
+ /**
165
+ * Return the fixed byte length of each scalar (r or s) for an ECDSA curve.
166
+ *
167
+ * | Algorithm | Curve | Byte length |
168
+ * |-----------|-------|-------------|
169
+ * | ES256 | P-256 | 32 |
170
+ * | ES384 | P-384 | 48 |
171
+ * | ES512 | P-521 | 66 |
172
+ */
173
+ function ecByteLength(alg) {
174
+ if (alg === 'ES256')
175
+ return 32;
176
+ if (alg === 'ES384')
177
+ return 48;
178
+ if (alg === 'ES512')
179
+ return 66;
180
+ throw new Error(`Not an EC algorithm: ${alg}`);
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // DER ↔ JOSE signature conversion (ECDSA only)
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Encode a DER length field.
187
+ * Uses short form for values < 128, otherwise one- or two-byte long form.
188
+ */
189
+ function encodeDerLength(len) {
190
+ if (len < 128)
191
+ return Buffer.from([len]);
192
+ if (len < 256)
193
+ return Buffer.from([0x81, len]);
194
+ return Buffer.from([0x82, len >> 8, len & 0xff]);
195
+ }
196
+ /**
197
+ * Convert a DER-encoded ECDSA signature (as produced by Node.js
198
+ * `crypto.sign()`) into the compact IEEE P1363 / JOSE format required by JWT:
199
+ * the concatenation of the fixed-width big-endian encodings of `r` and `s`.
200
+ *
201
+ * DER structure: `SEQUENCE { INTEGER r, INTEGER s }`
202
+ *
203
+ * @param der - DER-encoded ECDSA signature buffer.
204
+ * @param alg - ECDSA algorithm identifier used to determine the byte width.
205
+ * @returns A buffer of length `2 * ecByteLength(alg)`.
206
+ */
207
+ function derToJose(der, alg) {
208
+ const n = ecByteLength(alg);
209
+ let i = 0;
210
+ // SEQUENCE tag (0x30)
211
+ if (der[i++] !== 0x30)
212
+ throw new Error('ECDSA DER: expected SEQUENCE tag 0x30');
213
+ // SEQUENCE length (may be multi-byte for ES512)
214
+ if (der[i] & 0x80)
215
+ i += (der[i] & 0x7f) + 1;
216
+ else
217
+ i++;
218
+ /** Parse a single DER INTEGER and return its unsigned value as a Buffer. */
219
+ function readInt() {
220
+ if (der[i++] !== 0x02)
221
+ throw new Error('ECDSA DER: expected INTEGER tag 0x02');
222
+ let len = der[i++];
223
+ if (len & 0x80) {
224
+ const nb = len & 0x7f;
225
+ len = 0;
226
+ for (let k = 0; k < nb; k++)
227
+ len = (len << 8) | der[i++];
228
+ }
229
+ // Skip the leading 0x00 padding byte used to mark a positive integer.
230
+ if (der[i] === 0x00 && len > 1) {
231
+ i++;
232
+ len--;
233
+ }
234
+ const val = der.slice(i, i + len);
235
+ i += len;
236
+ return val;
237
+ }
238
+ const r = readInt();
239
+ const s = readInt();
240
+ const out = Buffer.alloc(2 * n, 0);
241
+ // Right-align within each fixed-width half (leading zeros already in buf).
242
+ r.copy(out, n - r.length);
243
+ s.copy(out, 2 * n - s.length);
244
+ return out;
245
+ }
246
+ /**
247
+ * Convert a compact IEEE P1363 / JOSE ECDSA signature (as used in JWT) back
248
+ * to the DER format expected by Node.js `crypto.verify()`.
249
+ *
250
+ * @param jose - Buffer of length `2 * ecByteLength(alg)` containing `r || s`.
251
+ * @param alg - ECDSA algorithm identifier used to determine the byte width.
252
+ * @returns A DER-encoded ECDSA signature buffer.
253
+ */
254
+ function joseToDer(jose, alg) {
255
+ const n = ecByteLength(alg);
256
+ const r = jose.slice(0, n);
257
+ const s = jose.slice(n);
258
+ /**
259
+ * Encode an unsigned integer as a DER INTEGER:
260
+ * strips leading zeros, prepends 0x00 when the MSB would be set,
261
+ * then wraps with tag (0x02) and length.
262
+ */
263
+ function encodeInt(buf) {
264
+ // Trim leading zeros (but keep at least one byte).
265
+ let start = 0;
266
+ while (start < buf.length - 1 && buf[start] === 0x00)
267
+ start++;
268
+ const trimmed = buf.slice(start);
269
+ // Prepend 0x00 if the MSB would be interpreted as a sign bit.
270
+ const padded = (trimmed[0] & 0x80)
271
+ ? Buffer.concat([Buffer.from([0x00]), trimmed])
272
+ : trimmed;
273
+ return Buffer.concat([Buffer.from([0x02, padded.length]), padded]);
274
+ }
275
+ const rDer = encodeInt(r);
276
+ const sDer = encodeInt(s);
277
+ const content = Buffer.concat([rDer, sDer]);
278
+ const lenBuf = encodeDerLength(content.length);
279
+ return Buffer.concat([Buffer.from([0x30]), lenBuf, content]);
280
+ }
281
+ // ---------------------------------------------------------------------------
282
+ // JWT utilities — manual Base64URL + sign/verify implementation
124
283
  // ---------------------------------------------------------------------------
125
284
  /**
126
285
  * Encode an arbitrary object as a Base64URL-encoded JSON string, suitable
@@ -148,53 +307,91 @@ function base64UrlDecode(str) {
148
307
  return JSON.parse(Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
149
308
  }
150
309
  /**
151
- * Compute the Base64URL-encoded HMAC signature for a JWT.
310
+ * Compute the Base64URL-encoded signature for a JWT signing input
311
+ * (`"<encodedHeader>.<encodedPayload>"`).
152
312
  *
153
- * Receives the **already-encoded** header and payload strings (i.e. the
154
- * first two dot-separated segments of the token) and signs the
155
- * `"<header>.<payload>"` string with the given secret.
313
+ * Dispatches to the correct signing primitive based on `alg`:
314
+ * - **HS*** `crypto.createHmac()` with `key` as shared secret.
315
+ * - **RS*** — `crypto.sign()` with `key` as PEM RSA private key (PKCS#1 v1.5).
316
+ * - **ES*** — `crypto.sign()` with `key` as PEM EC private key; the resulting
317
+ * DER-encoded signature is converted to compact JOSE (P1363) format.
156
318
  *
157
- * Currently supports the HS (HMAC-SHA) family: `HS256`, `HS384`, `HS512`.
319
+ * @param encodedHeader - Base64URL-encoded JWT header.
320
+ * @param encodedPayload - Base64URL-encoded JWT payload.
321
+ * @param key - HMAC secret (HS*) or PEM private key (RS* / ES*).
322
+ * @param alg - JWT algorithm identifier.
323
+ * @returns Base64URL-encoded signature string.
324
+ */
325
+ function computeSignature(encodedHeader, encodedPayload, key, alg) {
326
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
327
+ if (isHmac(alg)) {
328
+ return crypto
329
+ .createHmac(`sha${alg.slice(2)}`, key)
330
+ .update(signingInput)
331
+ .digest('base64')
332
+ .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
333
+ }
334
+ // Asymmetric: RS* or ES*
335
+ const hashAlg = nodeHashAlg(alg); // e.g. 'SHA256'
336
+ const derOrRaw = crypto.sign(hashAlg, Buffer.from(signingInput), key);
337
+ const sigBytes = isEc(alg) ? derToJose(derOrRaw, alg) : derOrRaw;
338
+ return sigBytes.toString('base64')
339
+ .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
340
+ }
341
+ /**
342
+ * Verify the signature of a JWT against the provided key.
343
+ *
344
+ * For HS* algorithms: computes the expected HMAC and compares using
345
+ * `crypto.timingSafeEqual()` to prevent timing attacks.
346
+ *
347
+ * For RS* / ES* algorithms: converts the Base64URL signature back to the format
348
+ * expected by Node.js (`raw` for RSA, `DER` for ECDSA) then calls
349
+ * `crypto.verify()`.
158
350
  *
159
- * @param encodedHeader - Base64URL-encoded JWT header string.
160
- * @param encodedPayload - Base64URL-encoded JWT payload string.
161
- * @param secret - HMAC secret key.
162
- * @param alg - Algorithm identifier (must be HS256, HS384, or HS512).
163
- * @returns A Base64URL-encoded signature string.
164
- * @throws {Error} When `alg` is not a supported HS algorithm.
351
+ * @param encodedHeader - Base64URL-encoded JWT header.
352
+ * @param encodedPayload - Base64URL-encoded JWT payload.
353
+ * @param b64sig - Base64URL-encoded signature from the token.
354
+ * @param key - HMAC secret (HS*) or PEM public key (RS* / ES*).
355
+ * @param alg - JWT algorithm identifier.
356
+ * @returns `true` when the signature is valid, `false` otherwise.
165
357
  */
166
- function createSignature(encodedHeader, encodedPayload, secret, alg) {
167
- // BUG FIX: the original code called `header.alg` where `header` was already
168
- // a Base64URL *string*, not a decoded object. Accessing `.alg` on a string
169
- // always returns `undefined`, causing every signature to fail. The algorithm
170
- // is now passed explicitly as a parameter instead of being read from the header.
171
- const shaVariant = `sha${alg.substring(2)}`; // 'sha256', 'sha384', 'sha512'
172
- return crypto_1.default
173
- .createHmac(shaVariant, secret)
174
- .update(`${encodedHeader}.${encodedPayload}`)
175
- .digest('base64')
176
- .replace(/=/g, '')
177
- .replace(/\+/g, '-')
178
- .replace(/\//g, '_');
358
+ function checkSignature(encodedHeader, encodedPayload, b64sig, key, alg) {
359
+ if (isHmac(alg)) {
360
+ const expected = computeSignature(encodedHeader, encodedPayload, key, alg);
361
+ const sigBuf = Buffer.from(b64sig);
362
+ const expectedBuf = Buffer.from(expected);
363
+ return sigBuf.length === expectedBuf.length &&
364
+ crypto.timingSafeEqual(sigBuf, expectedBuf);
365
+ }
366
+ // Asymmetric verification.
367
+ try {
368
+ const hashAlg = nodeHashAlg(alg);
369
+ const rawSig = Buffer.from(b64sig.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
370
+ const verBuf = isEc(alg) ? joseToDer(rawSig, alg) : rawSig;
371
+ const input = Buffer.from(`${encodedHeader}.${encodedPayload}`);
372
+ return crypto.verify(hashAlg, input, key, verBuf);
373
+ }
374
+ catch {
375
+ return false;
376
+ }
179
377
  }
180
378
  /**
181
379
  * Sign a payload object and return a compact JWT string.
182
380
  *
183
- * Automatically adds the `iat` (issued-at) and `exp` (expiration) claims.
184
- * Any claims already present in `payload` are preserved and take precedence
185
- * over `iat`/`exp` (use this to override expiry if needed).
381
+ * Automatically adds the `iat` (issued-at) and `exp` (expiration) claims,
382
+ * overriding any values already present in `payload`.
186
383
  *
187
384
  * @param payload - JWT payload claims (must be JSON-serialisable).
188
- * @param secret - HMAC secret used to sign the token.
385
+ * @param key - HMAC secret (HS*) or PEM private key (RS* / ES*).
189
386
  * @param expiresIn - Validity window in **seconds** from the current time.
190
387
  * @param alg - Signing algorithm. Defaults to `'HS256'`.
191
388
  * @returns A compact JWT string in the form `header.payload.signature`.
192
389
  */
193
- function signToken(payload, secret, expiresIn, alg = 'HS256') {
390
+ function signToken(payload, key, expiresIn, alg = 'HS256') {
194
391
  const now = Math.floor(Date.now() / 1000);
195
392
  const encodedHeader = base64UrlEncode({ alg, typ: 'JWT' });
196
393
  const fullPayload = base64UrlEncode({ ...payload, iat: now, exp: now + expiresIn });
197
- const signature = createSignature(encodedHeader, fullPayload, secret, alg);
394
+ const signature = computeSignature(encodedHeader, fullPayload, key, alg);
198
395
  return `${encodedHeader}.${fullPayload}.${signature}`;
199
396
  }
200
397
  /**
@@ -203,41 +400,28 @@ function signToken(payload, secret, expiresIn, alg = 'HS256') {
203
400
  * Performs the following checks in order:
204
401
  * 1. Structural validity (exactly three dot-separated segments).
205
402
  * 2. Algorithm consistency (header `alg` matches the expected `alg`).
206
- * 3. Signature integrity (timing-safe HMAC comparison).
403
+ * 3. Signature integrity.
207
404
  * 4. Expiration (`exp` claim is in the future).
208
405
  *
209
406
  * All errors are returned as `{ valid: false, error }` — no exception is
210
407
  * thrown to the caller.
211
408
  *
212
- * @param token - The compact JWT string to verify.
213
- * @param secret - HMAC secret that was used to sign the token.
214
- * @param alg - Expected signing algorithm.
409
+ * @param token - The compact JWT string to verify.
410
+ * @param key - HMAC secret (HS*) or PEM public key (RS* / ES*).
411
+ * @param alg - Expected signing algorithm.
215
412
  * @returns A {@link VerifyResult} discriminated union.
216
413
  */
217
- function verifyToken(token, secret, alg) {
414
+ function verifyToken(token, key, alg) {
218
415
  try {
219
416
  const parts = token.split('.');
220
417
  if (parts.length !== 3)
221
418
  return { valid: false, error: 'Invalid token format' };
222
419
  const [encodedHeader, encodedPayload, signature] = parts;
223
- // BUG FIX: the original code accessed `header.alg` where `header` is a
224
- // Base64URL *string*. `.alg` on a string is always `undefined`, so the
225
- // algorithm check always failed and every token was rejected. The header
226
- // must be decoded first.
227
420
  const decodedHeader = base64UrlDecode(encodedHeader);
228
421
  if (decodedHeader.alg !== alg)
229
422
  return { valid: false, error: 'Unauthorised signing algorithm' };
230
- const expectedSig = createSignature(encodedHeader, encodedPayload, secret, alg);
231
- // BUG FIX: `timingSafeEqual` requires both Buffers to have the same
232
- // length. When a forged token has a signature of a different length,
233
- // Node throws a RangeError instead of returning `false`. Guard against
234
- // this by checking lengths before calling timingSafeEqual.
235
- const sigBuf = Buffer.from(signature);
236
- const expectedBuf = Buffer.from(expectedSig);
237
- if (sigBuf.length !== expectedBuf.length ||
238
- !crypto_1.default.timingSafeEqual(sigBuf, expectedBuf)) {
423
+ if (!checkSignature(encodedHeader, encodedPayload, signature, key, alg))
239
424
  return { valid: false, error: 'Invalid signature' };
240
- }
241
425
  const payload = base64UrlDecode(encodedPayload);
242
426
  const now = Math.floor(Date.now() / 1000);
243
427
  if (payload.exp && payload.exp < now)
@@ -248,63 +432,101 @@ function verifyToken(token, secret, alg) {
248
432
  return { valid: false, error: 'Malformed token' };
249
433
  }
250
434
  }
251
- /**
252
- * Generate a cryptographically secure opaque refresh token.
253
- *
254
- * The token is 128 hex characters (64 random bytes), providing 512 bits of
255
- * entropy — far beyond any brute-force threat.
256
- *
257
- * @returns A 128-character lowercase hex string.
258
- */
259
- function generateRefreshToken() {
260
- return crypto_1.default.randomBytes(64).toString('hex');
261
- }
262
435
  // ---------------------------------------------------------------------------
263
436
  // Business logic
264
437
  // ---------------------------------------------------------------------------
438
+ /**
439
+ * Resolve the key used to **sign** access tokens.
440
+ * Returns the PEM private key for RS* / ES* algorithms, or the shared secret
441
+ * for HS* algorithms.
442
+ */
443
+ function accessSignKey(cfg) {
444
+ return isHmac(cfg.alg)
445
+ ? cfg.accessTokenSecret
446
+ : cfg.accessTokenPrivateKey;
447
+ }
448
+ /**
449
+ * Resolve the key used to **verify** access tokens.
450
+ * Returns the PEM public key for RS* / ES* algorithms, or the shared secret
451
+ * for HS* algorithms.
452
+ */
453
+ function accessVerifyKey(cfg) {
454
+ return isHmac(cfg.alg)
455
+ ? cfg.accessTokenSecret
456
+ : cfg.accessTokenPublicKey;
457
+ }
458
+ /**
459
+ * Resolve the key used to **sign** refresh tokens.
460
+ * Falls back to the access-token private key when no refresh-specific key
461
+ * is configured.
462
+ */
463
+ function refreshSignKey(cfg) {
464
+ return isHmac(cfg.alg)
465
+ ? cfg.refreshTokenSecret
466
+ : (cfg.refreshTokenPrivateKey ?? cfg.accessTokenPrivateKey);
467
+ }
468
+ /**
469
+ * Resolve the key used to **verify** refresh tokens.
470
+ * Falls back to the access-token public key when no refresh-specific key
471
+ * is configured.
472
+ */
473
+ function refreshVerifyKey(cfg) {
474
+ return isHmac(cfg.alg)
475
+ ? cfg.refreshTokenSecret
476
+ : (cfg.refreshTokenPublicKey ?? cfg.accessTokenPublicKey);
477
+ }
265
478
  /**
266
479
  * Authenticate a user by username and password and, on success, issue a new
267
- * access + refresh token pair.
480
+ * access token (and refresh token if a store is configured).
268
481
  *
269
482
  * @param username - The username supplied by the client.
270
483
  * @param password - The plain-text password supplied by the client.
271
484
  * @param config - Resolved plugin configuration.
272
485
  * @returns An {@link AuthResult} discriminated union.
273
486
  */
274
- function authenticateUser(username, password, config) {
275
- const user = config.fetchUser(username);
487
+ async function authenticateUser(username, password, config) {
488
+ const user = await config.fetchUser(username);
276
489
  if (!user)
277
490
  return { success: false, error: 'User not found' };
278
- // BUG FIX: the original used `checkPassword` which returned `true` when
279
- // the password was WRONG (negated logic). `isPasswordValid` returns `true`
280
- // when the password is correct.
281
- if (!config.isPasswordValid(user, password))
491
+ if (!await config.isPasswordValid(user, password))
282
492
  return { success: false, error: 'Incorrect password' };
283
493
  return issueTokenPair(user, config);
284
494
  }
285
495
  /**
286
- * Build and store a new access + refresh token pair for the given user.
496
+ * Build and store a new access token (and refresh token) for the given user.
287
497
  *
288
- * The refresh token is stored in `config.refreshTokenStore` with its
289
- * expiration timestamp so it can be validated on subsequent renewal requests.
498
+ * When `config.refreshTokenStore` is absent no refresh token is issued and
499
+ * the response omits the `refreshToken` field. When a store is present, a
500
+ * signed JWT refresh token is generated with a UUID v4 `jti` claim, the
501
+ * corresponding record is written to the store, and the full token string is
502
+ * returned.
290
503
  *
291
504
  * @param user - The authenticated user record.
292
505
  * @param config - Resolved plugin configuration.
293
506
  * @returns An {@link AuthResult} with `success: true`.
294
507
  */
295
- function issueTokenPair(user, config) {
296
- const username = config.username(user);
508
+ async function issueTokenPair(user, config) {
297
509
  const claims = config.payload(user);
298
510
  // Inject standard claims; caller-supplied claims take precedence.
299
511
  const fullClaims = {
300
- sub: username, // fallback subject — overridden by payload() if it sets sub
512
+ sub: config.username(user), // fallback subject — overridden by payload() if it sets sub
301
513
  ...claims,
302
514
  iss: config.issuer,
303
515
  };
304
- const accessToken = signToken(fullClaims, config.accessTokenSecret, config.accessTokenExpiry, config.alg);
305
- const refreshToken = generateRefreshToken();
306
- config.refreshTokenStore.set(refreshToken, {
307
- username,
516
+ const accessToken = signToken(fullClaims, accessSignKey(config), config.accessTokenExpiry, config.alg);
517
+ if (!config.refreshTokenStore) {
518
+ // No store configured — issue access token only.
519
+ return { success: true, accessToken, expiresIn: config.accessTokenExpiry, tokenType: 'Bearer' };
520
+ }
521
+ // Issue a signed JWT refresh token identified by a unique JTI.
522
+ const jti = crypto.randomUUID();
523
+ const sub = fullClaims.sub;
524
+ const refreshClaims = {
525
+ sub, jti, type: 'refresh', iss: config.issuer,
526
+ };
527
+ const refreshToken = signToken(refreshClaims, refreshSignKey(config), config.refreshTokenExpiry, config.alg);
528
+ await config.refreshTokenStore.set(jti, {
529
+ sub,
308
530
  issuedAt: Date.now(),
309
531
  expiresAt: Date.now() + config.refreshTokenExpiry * 1000,
310
532
  });
@@ -317,68 +539,89 @@ function issueTokenPair(user, config) {
317
539
  };
318
540
  }
319
541
  /**
320
- * Renew an access token using a valid refresh token.
542
+ * Renew an access token using a valid refresh token JWT.
321
543
  *
322
- * Implements **refresh token rotation**: the presented refresh token is
323
- * always invalidated and a brand-new pair is issued on success. This means
324
- * a stolen refresh token can only be used once before it is invalidated by
325
- * the legitimate holder's next renewal.
544
+ * Implements **refresh token rotation**: the presented JTI is always
545
+ * invalidated and a brand-new pair is issued on success. A stolen refresh
546
+ * token can only be used once before it is invalidated by the legitimate
547
+ * holder's next renewal.
326
548
  *
327
- * @param username - The username from the renewal request body.
328
- * @param refreshToken - The opaque refresh token string.
549
+ * @param refreshToken - The signed JWT refresh token string.
329
550
  * @param config - Resolved plugin configuration.
330
551
  * @returns An {@link AuthResult} discriminated union.
331
552
  */
332
- function renewAccessToken(username, refreshToken, config) {
333
- const tokenData = config.refreshTokenStore.get(refreshToken);
334
- // Verify the token exists and belongs to the claimed user.
335
- if (!tokenData || tokenData.username !== username)
553
+ async function renewAccessToken(refreshToken, config) {
554
+ if (!config.refreshTokenStore)
555
+ return { success: false, error: 'Refresh tokens are not configured' };
556
+ // Verify the refresh token JWT (uses refreshVerifyKey, not accessVerifyKey).
557
+ const result = verifyToken(refreshToken, refreshVerifyKey(config), config.alg);
558
+ if (!result.valid)
336
559
  return { success: false, error: 'Invalid or revoked refresh token' };
337
- if (Date.now() > tokenData.expiresAt) {
338
- config.refreshTokenStore.delete(refreshToken);
339
- return { success: false, error: 'Refresh token expired' };
340
- }
341
- const user = config.fetchUser(tokenData.username);
560
+ const refreshPayload = result.payload;
561
+ if (refreshPayload.type !== 'refresh')
562
+ return { success: false, error: 'Invalid token type' };
563
+ const jti = refreshPayload.jti;
564
+ if (!jti)
565
+ return { success: false, error: 'Invalid refresh token: missing jti' };
566
+ // Validate against the store (catches revoked or already-rotated tokens).
567
+ const record = await config.refreshTokenStore.get(jti);
568
+ if (!record)
569
+ return { success: false, error: 'Invalid or revoked refresh token' };
570
+ const user = await config.fetchUser(record.sub);
342
571
  if (!user) {
343
- config.refreshTokenStore.delete(refreshToken);
572
+ await config.refreshTokenStore.delete(jti);
344
573
  return { success: false, error: 'User not found' };
345
574
  }
346
- // Rotate: invalidate the used token before issuing a new pair.
347
- config.refreshTokenStore.delete(refreshToken);
575
+ // Rotate: invalidate the consumed JTI before issuing a new pair.
576
+ await config.refreshTokenStore.delete(jti);
348
577
  return issueTokenPair(user, config);
349
578
  }
350
579
  /**
351
- * Revoke a refresh token, preventing it from being used to obtain new access
352
- * tokens. Idempotent — revoking an already-revoked token is not an error.
580
+ * Revoke a refresh token by extracting its JTI and removing it from the
581
+ * store. Idempotent — revoking an already-revoked or malformed token is not
582
+ * an error.
353
583
  *
354
- * @param refreshToken - The opaque refresh token string to revoke.
584
+ * @param refreshToken - The signed JWT refresh token string to revoke.
355
585
  * @param config - Resolved plugin configuration.
356
- * @returns `true` if the token existed and was removed, `false` if it was
357
- * already absent.
358
586
  */
359
- function revokeRefreshToken(refreshToken, config) {
360
- // BUG FIX: the original function referenced `config` as a free variable but
361
- // `config` only exists inside `createJwtPlugin`. The function was declared
362
- // at module level and crashed with a ReferenceError at runtime. `config` is
363
- // now a required parameter.
364
- const existed = config.refreshTokenStore.has(refreshToken);
365
- config.refreshTokenStore.delete(refreshToken);
366
- return existed;
587
+ async function revokeRefreshToken(refreshToken, config) {
588
+ if (!config.refreshTokenStore)
589
+ return;
590
+ const result = verifyToken(refreshToken, refreshVerifyKey(config), config.alg);
591
+ if (!result.valid)
592
+ return;
593
+ const jti = result.payload.jti;
594
+ if (jti)
595
+ await config.refreshTokenStore.delete(jti);
367
596
  }
368
597
  /**
369
598
  * Create a JWT authentication plugin pre-configured with the given options.
370
599
  *
371
- * All config fields have safe defaults for development. At minimum, set
372
- * `accessTokenSecret` (and `refreshTokenSecret` if you plan to use it) to
373
- * random values in production.
600
+ * For **asymmetric algorithms** (RS* / ES*) you must provide at minimum
601
+ * `accessTokenPrivateKey` and `accessTokenPublicKey` in addition to setting
602
+ * `alg`. The refresh-token keys fall back to the access-token keys when
603
+ * `refreshTokenPrivateKey` / `refreshTokenPublicKey` are not set.
604
+ *
605
+ * Refresh tokens are only issued when `refreshTokenStore` is provided.
606
+ * Use {@link createMapTokenStore} for a simple in-process store.
374
607
  *
375
608
  * @param userConfig - Partial {@link JwtConfig} overrides.
376
609
  * @returns A {@link JwtPlugin} object exposing handlers and middleware.
610
+ * @throws {Error} When `alg` is RS* or ES* but the required PEM keys are absent.
377
611
  */
378
- function createJwtPlugin(userConfig = {}) {
612
+ export function createJwtPlugin(userConfig = {}) {
379
613
  const config = { ...DEFAULT_CONFIG, ...userConfig };
380
- // Helper: write a JSON response (our router's res.send() does not add
381
- // Content-Type automatically, so we set it manually).
614
+ // Validate asymmetric key requirements up front.
615
+ if (!isHmac(config.alg)) {
616
+ if (!config.accessTokenPrivateKey || !config.accessTokenPublicKey) {
617
+ throw new Error(`Algorithm '${config.alg}' requires both 'accessTokenPrivateKey' and ` +
618
+ `'accessTokenPublicKey' in JwtConfig.`);
619
+ }
620
+ }
621
+ /**
622
+ * Write a JSON response. Sets `Content-Type` explicitly since the router's
623
+ * `res.send()` does not add it automatically.
624
+ */
382
625
  function sendJson(res, status, data) {
383
626
  const body = JSON.stringify(data);
384
627
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -388,55 +631,60 @@ function createJwtPlugin(userConfig = {}) {
388
631
  /**
389
632
  * Login handler. Reads `{ username, password }` from `req.body` (requires
390
633
  * a JSON body-parsing middleware such as `json()` to run first).
634
+ * When `refreshTokenStore` is absent the response omits `refreshToken`.
391
635
  */
392
636
  const login = (req, res) => {
393
- const { username, password } = req.body ?? {};
394
- if (!username || !password) {
395
- sendJson(res, 400, { error: "Fields 'username' and 'password' are required" });
396
- return;
397
- }
398
- const result = authenticateUser(username, password, config);
399
- if (!result.success) {
400
- sendJson(res, 401, { error: result.error });
401
- return;
402
- }
403
- sendJson(res, 200, {
404
- message: 'Authentication successful',
405
- accessToken: result.accessToken,
406
- refreshToken: result.refreshToken,
407
- expiresIn: result.expiresIn,
408
- tokenType: result.tokenType,
409
- });
637
+ (async () => {
638
+ const { username, password } = req.body ?? {};
639
+ if (!username || !password) {
640
+ sendJson(res, 400, { error: "Fields 'username' and 'password' are required" });
641
+ return;
642
+ }
643
+ const result = await authenticateUser(username, password, config);
644
+ if (!result.success) {
645
+ sendJson(res, 401, { error: result.error });
646
+ return;
647
+ }
648
+ const body = {
649
+ message: 'Authentication successful',
650
+ accessToken: result.accessToken,
651
+ expiresIn: result.expiresIn,
652
+ tokenType: result.tokenType,
653
+ };
654
+ if (result.refreshToken !== undefined)
655
+ body.refreshToken = result.refreshToken;
656
+ sendJson(res, 200, body);
657
+ })().catch(() => sendJson(res, 500, { error: 'Internal server error' }));
410
658
  };
411
659
  // ── POST /auth/refresh ──────────────────────────────────────────────────
412
660
  /**
413
- * Token-renewal handler. Reads `{ username, refreshToken }` from
414
- * `req.body`.
415
- *
416
- * BUG FIX: the original accepted a renewal request without `username`,
417
- * allowing `renewAccessToken(undefined, token, config)` to be called.
418
- * Because `tokenData.username !== undefined` is always `true` for any real
419
- * token, any holder of a refresh token could silently impersonate its owner.
420
- * `username` is now validated as a required field.
661
+ * Token-renewal handler. Reads `{ refreshToken }` from `req.body`.
662
+ * Responds with 501 when no `refreshTokenStore` is configured.
421
663
  */
422
664
  const refresh = (req, res) => {
423
- const { username, refreshToken } = req.body ?? {};
424
- if (!username || !refreshToken) {
425
- sendJson(res, 400, { error: "Fields 'username' and 'refreshToken' are required" });
426
- return;
427
- }
428
- const result = renewAccessToken(username, refreshToken, config);
429
- if (!result.success) {
430
- sendJson(res, 401, { error: result.error });
431
- return;
432
- }
433
- sendJson(res, 200, {
434
- message: 'Token renewed successfully',
435
- accessToken: result.accessToken,
436
- refreshToken: result.refreshToken,
437
- expiresIn: result.expiresIn,
438
- tokenType: result.tokenType,
439
- });
665
+ (async () => {
666
+ if (!config.refreshTokenStore) {
667
+ sendJson(res, 501, { error: 'Refresh tokens are not configured' });
668
+ return;
669
+ }
670
+ const { refreshToken } = req.body ?? {};
671
+ if (!refreshToken) {
672
+ sendJson(res, 400, { error: "Field 'refreshToken' is required" });
673
+ return;
674
+ }
675
+ const result = await renewAccessToken(refreshToken, config);
676
+ if (!result.success) {
677
+ sendJson(res, 401, { error: result.error });
678
+ return;
679
+ }
680
+ sendJson(res, 200, {
681
+ message: 'Token renewed successfully',
682
+ accessToken: result.accessToken,
683
+ refreshToken: result.refreshToken,
684
+ expiresIn: result.expiresIn,
685
+ tokenType: result.tokenType,
686
+ });
687
+ })().catch(() => sendJson(res, 500, { error: 'Internal server error' }));
440
688
  };
441
689
  // ── POST /auth/logout ───────────────────────────────────────────────────
442
690
  /**
@@ -445,26 +693,25 @@ function createJwtPlugin(userConfig = {}) {
445
693
  * provided (to avoid leaking information about token existence).
446
694
  */
447
695
  const logout = (req, res) => {
448
- const { refreshToken } = req.body ?? {};
449
- if (refreshToken) {
450
- // BUG FIX: the original called `revokeRefreshToken(refreshToken)` without
451
- // passing `config`, which caused a ReferenceError because `config` is a
452
- // local variable inside `createJwtPlugin`.
453
- revokeRefreshToken(refreshToken, config);
454
- }
455
- sendJson(res, 200, { message: 'Logged out successfully' });
696
+ (async () => {
697
+ const { refreshToken } = req.body ?? {};
698
+ if (refreshToken && config.refreshTokenStore) {
699
+ await revokeRefreshToken(refreshToken, config);
700
+ }
701
+ sendJson(res, 200, { message: 'Logged out successfully' });
702
+ })().catch(() => sendJson(res, 500, { error: 'Internal server error' }));
456
703
  };
457
704
  // ── authenticate middleware ─────────────────────────────────────────────
458
705
  /**
459
706
  * Validate the `Authorization: Bearer <token>` header and populate
460
- * `req.user` with the decoded payload.
707
+ * `req.user` with the decoded access-token payload.
461
708
  *
462
709
  * Designed to be **non-blocking**: missing or invalid tokens cause `next()`
463
710
  * to be called without error, deferring the authentication decision to the
464
711
  * next middleware (typically {@link authorize} or a custom guard).
465
712
  *
466
713
  * `req.user` is explicitly cleared at the start of each invocation to
467
- * prevent stale data from leaking across requests in unusual server setups.
714
+ * prevent stale data from leaking across requests.
468
715
  */
469
716
  const authenticate = (req, res, next) => {
470
717
  // Always clear any previously set user to prevent cross-request contamination.
@@ -473,13 +720,9 @@ function createJwtPlugin(userConfig = {}) {
473
720
  if (!authHeader?.startsWith('Bearer '))
474
721
  return next();
475
722
  const token = authHeader.slice(7);
476
- const result = verifyToken(token, config.accessTokenSecret, config.alg);
723
+ const result = verifyToken(token, accessVerifyKey(config), config.alg);
477
724
  if (!result.valid)
478
725
  return next();
479
- // BUG FIX: the original compared `result.payload` (an object) to
480
- // `config.issuer` (a string) with `!=`, which is always `true`, causing
481
- // every token to be rejected when `checkIssuer` was enabled.
482
- // The correct check reads the `iss` claim from the decoded payload.
483
726
  if (config.checkIssuer && result.payload.iss !== config.issuer)
484
727
  return next();
485
728
  req.user = result.payload;
@@ -505,9 +748,6 @@ function createJwtPlugin(userConfig = {}) {
505
748
  *
506
749
  * Responds with 401 when unauthenticated, 403 when none of the required
507
750
  * roles are present.
508
- *
509
- * BUG FIX: the original accessed `req.user.roles` without first checking
510
- * that `req.user` exists, throwing a TypeError for unauthenticated requests.
511
751
  */
512
752
  function requireRole(...roles) {
513
753
  return [
@@ -537,8 +777,6 @@ function createJwtPlugin(userConfig = {}) {
537
777
  * **all** of the specified permissions.
538
778
  *
539
779
  * Responds with 401 when unauthenticated, 403 when any permission is absent.
540
- *
541
- * BUG FIX: same `req.user` undefined-access issue as `requireRole`.
542
780
  */
543
781
  function requirePermission(...permissions) {
544
782
  return [
@@ -571,5 +809,7 @@ function createJwtPlugin(userConfig = {}) {
571
809
  requirePermission,
572
810
  };
573
811
  }
574
- exports.default = createJwtPlugin;
812
+ export default createJwtPlugin;
813
+ // Re-export low-level utilities for testing and advanced use cases.
814
+ export { signToken, verifyToken, hashPassword as _hashPassword };
575
815
  //# sourceMappingURL=jwt-auth.js.map