expediate 1.0.4 → 1.0.6
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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +47 -8
- 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
|
|
29
|
-
*
|
|
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
|
-
* -
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: (
|
|
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.
|
|
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
|
-
//
|
|
153
|
+
// Algorithm helpers
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
/** True for HMAC-based algorithms (HS256 / HS384 / HS512). */
|
|
156
|
+
function isHmac(alg) { return alg.startsWith('H'); }
|
|
157
|
+
/** True for ECDSA-based algorithms (ES256 / ES384 / ES512). */
|
|
158
|
+
function isEc(alg) { return alg.startsWith('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
|
|
310
|
+
* Compute the Base64URL-encoded signature for a JWT signing input
|
|
311
|
+
* (`"<encodedHeader>.<encodedPayload>"`).
|
|
152
312
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* `
|
|
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
|
-
*
|
|
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
|
|
160
|
-
* @param encodedPayload - Base64URL-encoded JWT payload
|
|
161
|
-
* @param
|
|
162
|
-
* @param
|
|
163
|
-
* @
|
|
164
|
-
* @
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
.replace(
|
|
178
|
-
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
213
|
-
* @param
|
|
214
|
-
* @param alg
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
496
|
+
* Build and store a new access token (and refresh token) for the given user.
|
|
287
497
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
297
|
-
const claims = config.payload(user);
|
|
508
|
+
async function issueTokenPair(user, config) {
|
|
509
|
+
const claims = await 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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
|
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(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
return { success: false, error: '
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
572
|
+
await config.refreshTokenStore.delete(jti);
|
|
344
573
|
return { success: false, error: 'User not found' };
|
|
345
574
|
}
|
|
346
|
-
// Rotate: invalidate the
|
|
347
|
-
config.refreshTokenStore.delete(
|
|
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
|
|
352
|
-
*
|
|
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
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
372
|
-
* `
|
|
373
|
-
*
|
|
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
|
-
//
|
|
381
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 `{
|
|
414
|
-
* `
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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,41 +693,36 @@ function createJwtPlugin(userConfig = {}) {
|
|
|
445
693
|
* provided (to avoid leaking information about token existence).
|
|
446
694
|
*/
|
|
447
695
|
const logout = (req, res) => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
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.
|
|
471
718
|
delete req.user;
|
|
472
|
-
const authHeader = req.headers
|
|
719
|
+
const authHeader = req.headers.authorization;
|
|
473
720
|
if (!authHeader?.startsWith('Bearer '))
|
|
474
721
|
return next();
|
|
475
722
|
const token = authHeader.slice(7);
|
|
476
|
-
const result = verifyToken(token, config
|
|
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
|
-
|
|
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
|