expediate 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.npmignore +2 -1
- package/package.json +3 -4
- package/src/apis.ts +428 -0
- package/src/git.ts +326 -0
- package/src/index.ts +85 -0
- package/src/jwt-auth.ts +861 -0
- package/src/mimetypes.json +1 -0
- package/src/misc.ts +734 -0
- package/src/router.ts +736 -0
- package/src/static.ts +904 -0
- package/dist/apis.js +0 -250
- package/dist/git.js +0 -244
- package/dist/jwt-auth.js +0 -575
- package/dist/misc.js +0 -549
- package/dist/router.js +0 -502
- package/dist/static.js +0 -703
package/dist/jwt-auth.js
DELETED
|
@@ -1,575 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/* Copyright 2021 Fabien Bavent
|
|
3
|
-
*
|
|
4
|
-
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
5
|
-
* copy of this software and associated documentation files (the "Software"),
|
|
6
|
-
* to deal in the Software without restriction, including without limitation
|
|
7
|
-
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
8
|
-
* and/or sell copies of the Software, and to permit persons to whom the
|
|
9
|
-
* Software is furnished to do so, subject to the following conditions:
|
|
10
|
-
*
|
|
11
|
-
* The above copyright notice and this permission notice shall be included
|
|
12
|
-
* in all copies or substantial portions of the Software.
|
|
13
|
-
*
|
|
14
|
-
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
15
|
-
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
-
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
17
|
-
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
-
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
19
|
-
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
20
|
-
* DEALINGS IN THE SOFTWARE.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* jwt-auth.ts
|
|
24
|
-
*
|
|
25
|
-
* JWT authentication plugin for the Expediate router.
|
|
26
|
-
*
|
|
27
|
-
* Provides:
|
|
28
|
-
* - Stateless access tokens (HS256 / HS384 / HS512 HMAC-signed JWTs).
|
|
29
|
-
* - Opaque refresh tokens with server-side storage and automatic rotation.
|
|
30
|
-
* - Route handlers for login, token refresh, and logout.
|
|
31
|
-
* - Middleware for token validation, authorisation, role checks, and
|
|
32
|
-
* permission checks.
|
|
33
|
-
*
|
|
34
|
-
* Security notes:
|
|
35
|
-
* - Passwords are hashed with SHA-256 for demonstration purposes only.
|
|
36
|
-
* Replace with bcrypt / argon2 in production.
|
|
37
|
-
* - 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.
|
|
40
|
-
*/
|
|
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"));
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Sample user database (replace or extend for real applications)
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
/**
|
|
56
|
-
* Hash a plain-text password with SHA-256 and return the hex digest.
|
|
57
|
-
*
|
|
58
|
-
* > **⚠ Warning:** SHA-256 is fast and therefore unsuitable for password
|
|
59
|
-
* > hashing in production. Use bcrypt or argon2 instead.
|
|
60
|
-
*
|
|
61
|
-
* @param password - The plain-text password to hash.
|
|
62
|
-
* @returns A 64-character lowercase hex string.
|
|
63
|
-
*/
|
|
64
|
-
function hashPassword(password) {
|
|
65
|
-
return crypto_1.default.createHash('sha256').update(password).digest('hex');
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Default in-memory user database used when no custom `fetchUser` is
|
|
69
|
-
* provided. Contains three demo accounts: alice (admin), bob (editor),
|
|
70
|
-
* charlie (viewer).
|
|
71
|
-
*
|
|
72
|
-
* Replace or ignore this map entirely when you supply your own `fetchUser`.
|
|
73
|
-
*/
|
|
74
|
-
exports.userDatabase = new Map([
|
|
75
|
-
['alice', {
|
|
76
|
-
id: 'usr_001',
|
|
77
|
-
username: 'alice',
|
|
78
|
-
passwordHash: hashPassword('password123'),
|
|
79
|
-
roles: ['admin', 'editor'],
|
|
80
|
-
permissions: ['read', 'write', 'delete', 'manage_users'],
|
|
81
|
-
}],
|
|
82
|
-
['bob', {
|
|
83
|
-
id: 'usr_002',
|
|
84
|
-
username: 'bob',
|
|
85
|
-
passwordHash: hashPassword('secret456'),
|
|
86
|
-
roles: ['editor'],
|
|
87
|
-
permissions: ['read', 'write'],
|
|
88
|
-
}],
|
|
89
|
-
['charlie', {
|
|
90
|
-
id: 'usr_003',
|
|
91
|
-
username: 'charlie',
|
|
92
|
-
passwordHash: hashPassword('pass789'),
|
|
93
|
-
roles: ['viewer'],
|
|
94
|
-
permissions: ['read'],
|
|
95
|
-
}],
|
|
96
|
-
]);
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Default configuration
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
const DEFAULT_CONFIG = {
|
|
101
|
-
accessTokenSecret: 'access-secret-change-in-production',
|
|
102
|
-
refreshTokenSecret: 'refresh-secret-change-in-production',
|
|
103
|
-
accessTokenExpiry: 15 * 60, // 15 minutes
|
|
104
|
-
refreshTokenExpiry: 7 * 24 * 3600, // 7 days
|
|
105
|
-
issuer: 'jwt-auth',
|
|
106
|
-
checkIssuer: false,
|
|
107
|
-
alg: 'HS256',
|
|
108
|
-
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.
|
|
113
|
-
isPasswordValid: (user, password) => user.passwordHash === hashPassword(password),
|
|
114
|
-
payload: (user) => ({
|
|
115
|
-
sub: user.id,
|
|
116
|
-
username: user.username,
|
|
117
|
-
roles: user.roles,
|
|
118
|
-
permissions: user.permissions,
|
|
119
|
-
}),
|
|
120
|
-
refreshTokenStore: new Map(),
|
|
121
|
-
};
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// JWT utilities — manual Base64URL implementation
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
/**
|
|
126
|
-
* Encode an arbitrary object as a Base64URL-encoded JSON string, suitable
|
|
127
|
-
* for use as a JWT header or payload segment.
|
|
128
|
-
*
|
|
129
|
-
* @param data - Any JSON-serialisable value.
|
|
130
|
-
* @returns A Base64URL string with no padding characters.
|
|
131
|
-
*/
|
|
132
|
-
function base64UrlEncode(data) {
|
|
133
|
-
return Buffer.from(JSON.stringify(data))
|
|
134
|
-
.toString('base64')
|
|
135
|
-
.replace(/=/g, '')
|
|
136
|
-
.replace(/\+/g, '-')
|
|
137
|
-
.replace(/\//g, '_');
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Decode a Base64URL-encoded JWT segment and parse it as JSON.
|
|
141
|
-
*
|
|
142
|
-
* @param str - A Base64URL string (padding optional).
|
|
143
|
-
* @returns The parsed JSON value.
|
|
144
|
-
* @throws When the string is not valid Base64URL JSON.
|
|
145
|
-
*/
|
|
146
|
-
function base64UrlDecode(str) {
|
|
147
|
-
const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
|
|
148
|
-
return JSON.parse(Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Compute the Base64URL-encoded HMAC signature for a JWT.
|
|
152
|
-
*
|
|
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.
|
|
156
|
-
*
|
|
157
|
-
* Currently supports the HS (HMAC-SHA) family: `HS256`, `HS384`, `HS512`.
|
|
158
|
-
*
|
|
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.
|
|
165
|
-
*/
|
|
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, '_');
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Sign a payload object and return a compact JWT string.
|
|
182
|
-
*
|
|
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).
|
|
186
|
-
*
|
|
187
|
-
* @param payload - JWT payload claims (must be JSON-serialisable).
|
|
188
|
-
* @param secret - HMAC secret used to sign the token.
|
|
189
|
-
* @param expiresIn - Validity window in **seconds** from the current time.
|
|
190
|
-
* @param alg - Signing algorithm. Defaults to `'HS256'`.
|
|
191
|
-
* @returns A compact JWT string in the form `header.payload.signature`.
|
|
192
|
-
*/
|
|
193
|
-
function signToken(payload, secret, expiresIn, alg = 'HS256') {
|
|
194
|
-
const now = Math.floor(Date.now() / 1000);
|
|
195
|
-
const encodedHeader = base64UrlEncode({ alg, typ: 'JWT' });
|
|
196
|
-
const fullPayload = base64UrlEncode({ ...payload, iat: now, exp: now + expiresIn });
|
|
197
|
-
const signature = createSignature(encodedHeader, fullPayload, secret, alg);
|
|
198
|
-
return `${encodedHeader}.${fullPayload}.${signature}`;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Verify a compact JWT string and return its decoded payload on success.
|
|
202
|
-
*
|
|
203
|
-
* Performs the following checks in order:
|
|
204
|
-
* 1. Structural validity (exactly three dot-separated segments).
|
|
205
|
-
* 2. Algorithm consistency (header `alg` matches the expected `alg`).
|
|
206
|
-
* 3. Signature integrity (timing-safe HMAC comparison).
|
|
207
|
-
* 4. Expiration (`exp` claim is in the future).
|
|
208
|
-
*
|
|
209
|
-
* All errors are returned as `{ valid: false, error }` — no exception is
|
|
210
|
-
* thrown to the caller.
|
|
211
|
-
*
|
|
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.
|
|
215
|
-
* @returns A {@link VerifyResult} discriminated union.
|
|
216
|
-
*/
|
|
217
|
-
function verifyToken(token, secret, alg) {
|
|
218
|
-
try {
|
|
219
|
-
const parts = token.split('.');
|
|
220
|
-
if (parts.length !== 3)
|
|
221
|
-
return { valid: false, error: 'Invalid token format' };
|
|
222
|
-
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
|
-
const decodedHeader = base64UrlDecode(encodedHeader);
|
|
228
|
-
if (decodedHeader.alg !== alg)
|
|
229
|
-
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)) {
|
|
239
|
-
return { valid: false, error: 'Invalid signature' };
|
|
240
|
-
}
|
|
241
|
-
const payload = base64UrlDecode(encodedPayload);
|
|
242
|
-
const now = Math.floor(Date.now() / 1000);
|
|
243
|
-
if (payload.exp && payload.exp < now)
|
|
244
|
-
return { valid: false, error: 'Token expired' };
|
|
245
|
-
return { valid: true, payload };
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
return { valid: false, error: 'Malformed token' };
|
|
249
|
-
}
|
|
250
|
-
}
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
263
|
-
// Business logic
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
/**
|
|
266
|
-
* Authenticate a user by username and password and, on success, issue a new
|
|
267
|
-
* access + refresh token pair.
|
|
268
|
-
*
|
|
269
|
-
* @param username - The username supplied by the client.
|
|
270
|
-
* @param password - The plain-text password supplied by the client.
|
|
271
|
-
* @param config - Resolved plugin configuration.
|
|
272
|
-
* @returns An {@link AuthResult} discriminated union.
|
|
273
|
-
*/
|
|
274
|
-
function authenticateUser(username, password, config) {
|
|
275
|
-
const user = config.fetchUser(username);
|
|
276
|
-
if (!user)
|
|
277
|
-
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))
|
|
282
|
-
return { success: false, error: 'Incorrect password' };
|
|
283
|
-
return issueTokenPair(user, config);
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Build and store a new access + refresh token pair for the given user.
|
|
287
|
-
*
|
|
288
|
-
* The refresh token is stored in `config.refreshTokenStore` with its
|
|
289
|
-
* expiration timestamp so it can be validated on subsequent renewal requests.
|
|
290
|
-
*
|
|
291
|
-
* @param user - The authenticated user record.
|
|
292
|
-
* @param config - Resolved plugin configuration.
|
|
293
|
-
* @returns An {@link AuthResult} with `success: true`.
|
|
294
|
-
*/
|
|
295
|
-
function issueTokenPair(user, config) {
|
|
296
|
-
const username = config.username(user);
|
|
297
|
-
const claims = config.payload(user);
|
|
298
|
-
// Inject standard claims; caller-supplied claims take precedence.
|
|
299
|
-
const fullClaims = {
|
|
300
|
-
sub: username, // fallback subject — overridden by payload() if it sets sub
|
|
301
|
-
...claims,
|
|
302
|
-
iss: config.issuer,
|
|
303
|
-
};
|
|
304
|
-
const accessToken = signToken(fullClaims, config.accessTokenSecret, config.accessTokenExpiry, config.alg);
|
|
305
|
-
const refreshToken = generateRefreshToken();
|
|
306
|
-
config.refreshTokenStore.set(refreshToken, {
|
|
307
|
-
username,
|
|
308
|
-
issuedAt: Date.now(),
|
|
309
|
-
expiresAt: Date.now() + config.refreshTokenExpiry * 1000,
|
|
310
|
-
});
|
|
311
|
-
return {
|
|
312
|
-
success: true,
|
|
313
|
-
accessToken,
|
|
314
|
-
refreshToken,
|
|
315
|
-
expiresIn: config.accessTokenExpiry,
|
|
316
|
-
tokenType: 'Bearer',
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Renew an access token using a valid refresh token.
|
|
321
|
-
*
|
|
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.
|
|
326
|
-
*
|
|
327
|
-
* @param username - The username from the renewal request body.
|
|
328
|
-
* @param refreshToken - The opaque refresh token string.
|
|
329
|
-
* @param config - Resolved plugin configuration.
|
|
330
|
-
* @returns An {@link AuthResult} discriminated union.
|
|
331
|
-
*/
|
|
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)
|
|
336
|
-
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);
|
|
342
|
-
if (!user) {
|
|
343
|
-
config.refreshTokenStore.delete(refreshToken);
|
|
344
|
-
return { success: false, error: 'User not found' };
|
|
345
|
-
}
|
|
346
|
-
// Rotate: invalidate the used token before issuing a new pair.
|
|
347
|
-
config.refreshTokenStore.delete(refreshToken);
|
|
348
|
-
return issueTokenPair(user, config);
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
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.
|
|
353
|
-
*
|
|
354
|
-
* @param refreshToken - The opaque refresh token string to revoke.
|
|
355
|
-
* @param config - Resolved plugin configuration.
|
|
356
|
-
* @returns `true` if the token existed and was removed, `false` if it was
|
|
357
|
-
* already absent.
|
|
358
|
-
*/
|
|
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;
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Create a JWT authentication plugin pre-configured with the given options.
|
|
370
|
-
*
|
|
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.
|
|
374
|
-
*
|
|
375
|
-
* @param userConfig - Partial {@link JwtConfig} overrides.
|
|
376
|
-
* @returns A {@link JwtPlugin} object exposing handlers and middleware.
|
|
377
|
-
*/
|
|
378
|
-
function createJwtPlugin(userConfig = {}) {
|
|
379
|
-
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).
|
|
382
|
-
function sendJson(res, status, data) {
|
|
383
|
-
const body = JSON.stringify(data);
|
|
384
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
385
|
-
res.status(status).send(body);
|
|
386
|
-
}
|
|
387
|
-
// ── POST /auth/login ────────────────────────────────────────────────────
|
|
388
|
-
/**
|
|
389
|
-
* Login handler. Reads `{ username, password }` from `req.body` (requires
|
|
390
|
-
* a JSON body-parsing middleware such as `json()` to run first).
|
|
391
|
-
*/
|
|
392
|
-
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
|
-
});
|
|
410
|
-
};
|
|
411
|
-
// ── POST /auth/refresh ──────────────────────────────────────────────────
|
|
412
|
-
/**
|
|
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.
|
|
421
|
-
*/
|
|
422
|
-
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
|
-
});
|
|
440
|
-
};
|
|
441
|
-
// ── POST /auth/logout ───────────────────────────────────────────────────
|
|
442
|
-
/**
|
|
443
|
-
* Logout handler. Optionally reads `{ refreshToken }` from `req.body` and
|
|
444
|
-
* revokes it. Always responds with 200 regardless of whether a token was
|
|
445
|
-
* provided (to avoid leaking information about token existence).
|
|
446
|
-
*/
|
|
447
|
-
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' });
|
|
456
|
-
};
|
|
457
|
-
// ── authenticate middleware ─────────────────────────────────────────────
|
|
458
|
-
/**
|
|
459
|
-
* Validate the `Authorization: Bearer <token>` header and populate
|
|
460
|
-
* `req.user` with the decoded payload.
|
|
461
|
-
*
|
|
462
|
-
* Designed to be **non-blocking**: missing or invalid tokens cause `next()`
|
|
463
|
-
* to be called without error, deferring the authentication decision to the
|
|
464
|
-
* next middleware (typically {@link authorize} or a custom guard).
|
|
465
|
-
*
|
|
466
|
-
* `req.user` is explicitly cleared at the start of each invocation to
|
|
467
|
-
* prevent stale data from leaking across requests in unusual server setups.
|
|
468
|
-
*/
|
|
469
|
-
const authenticate = (req, res, next) => {
|
|
470
|
-
// Always clear any previously set user to prevent cross-request contamination.
|
|
471
|
-
delete req.user;
|
|
472
|
-
const authHeader = req.headers['authorization'];
|
|
473
|
-
if (!authHeader?.startsWith('Bearer '))
|
|
474
|
-
return next();
|
|
475
|
-
const token = authHeader.slice(7);
|
|
476
|
-
const result = verifyToken(token, config.accessTokenSecret, config.alg);
|
|
477
|
-
if (!result.valid)
|
|
478
|
-
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
|
-
if (config.checkIssuer && result.payload.iss !== config.issuer)
|
|
484
|
-
return next();
|
|
485
|
-
req.user = result.payload;
|
|
486
|
-
next();
|
|
487
|
-
};
|
|
488
|
-
// ── authorize middleware ────────────────────────────────────────────────
|
|
489
|
-
/**
|
|
490
|
-
* Reject the request with 401 when `req.user` is not set.
|
|
491
|
-
* Always place this **after** {@link authenticate}.
|
|
492
|
-
*/
|
|
493
|
-
const authorize = (req, res, next) => {
|
|
494
|
-
if (!req.user) {
|
|
495
|
-
sendJson(res, 401, { error: 'Authentication required' });
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
next();
|
|
499
|
-
};
|
|
500
|
-
// ── requireRole factory ─────────────────────────────────────────────────
|
|
501
|
-
/**
|
|
502
|
-
* Return a two-element middleware chain `[authenticate, roleCheck]` that
|
|
503
|
-
* allows the request to proceed only when the authenticated user holds at
|
|
504
|
-
* least one of the specified roles.
|
|
505
|
-
*
|
|
506
|
-
* Responds with 401 when unauthenticated, 403 when none of the required
|
|
507
|
-
* 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
|
-
*/
|
|
512
|
-
function requireRole(...roles) {
|
|
513
|
-
return [
|
|
514
|
-
authenticate,
|
|
515
|
-
(req, res, next) => {
|
|
516
|
-
const user = req.user;
|
|
517
|
-
if (!user) {
|
|
518
|
-
sendJson(res, 401, { error: 'Authentication required' });
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const userRoles = user.roles ?? [];
|
|
522
|
-
if (!roles.some((r) => userRoles.includes(r))) {
|
|
523
|
-
sendJson(res, 403, {
|
|
524
|
-
error: `Access denied. Required role(s): ${roles.join(', ')}`,
|
|
525
|
-
yourRoles: userRoles,
|
|
526
|
-
});
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
next();
|
|
530
|
-
},
|
|
531
|
-
];
|
|
532
|
-
}
|
|
533
|
-
// ── requirePermission factory ───────────────────────────────────────────
|
|
534
|
-
/**
|
|
535
|
-
* Return a two-element middleware chain `[authenticate, permCheck]` that
|
|
536
|
-
* allows the request to proceed only when the authenticated user holds
|
|
537
|
-
* **all** of the specified permissions.
|
|
538
|
-
*
|
|
539
|
-
* Responds with 401 when unauthenticated, 403 when any permission is absent.
|
|
540
|
-
*
|
|
541
|
-
* BUG FIX: same `req.user` undefined-access issue as `requireRole`.
|
|
542
|
-
*/
|
|
543
|
-
function requirePermission(...permissions) {
|
|
544
|
-
return [
|
|
545
|
-
authenticate,
|
|
546
|
-
(req, res, next) => {
|
|
547
|
-
const user = req.user;
|
|
548
|
-
if (!user) {
|
|
549
|
-
sendJson(res, 401, { error: 'Authentication required' });
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
const userPerms = user.permissions ?? [];
|
|
553
|
-
if (!permissions.every((p) => userPerms.includes(p))) {
|
|
554
|
-
sendJson(res, 403, {
|
|
555
|
-
error: `Insufficient permissions. Required: ${permissions.join(', ')}`,
|
|
556
|
-
yourPermissions: userPerms,
|
|
557
|
-
});
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
next();
|
|
561
|
-
},
|
|
562
|
-
];
|
|
563
|
-
}
|
|
564
|
-
return {
|
|
565
|
-
login,
|
|
566
|
-
refresh,
|
|
567
|
-
logout,
|
|
568
|
-
authenticate,
|
|
569
|
-
authorize,
|
|
570
|
-
requireRole,
|
|
571
|
-
requirePermission,
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
exports.default = createJwtPlugin;
|
|
575
|
-
//# sourceMappingURL=jwt-auth.js.map
|