@xenterprises/fastify-xauth-local 1.0.0
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/.gitlab-ci.yml +27 -0
- package/README.md +453 -0
- package/package.json +39 -0
- package/src/routes/local.js +531 -0
- package/src/services/jwt.js +143 -0
- package/src/services/middleware.js +177 -0
- package/src/xAuthLocal.js +242 -0
- package/test/xAuthLocal.test.js +744 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Authentication Routes
|
|
3
|
+
*
|
|
4
|
+
* Provides local authentication endpoints: login, me, register, password-reset
|
|
5
|
+
* Compatible with existing frontend patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import bcrypt from "bcryptjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default user lookup - should be overridden
|
|
12
|
+
*/
|
|
13
|
+
const defaultUserLookup = async () => {
|
|
14
|
+
throw new Error("xAuthLocal: userLookup function not configured");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default user creation - should be overridden
|
|
19
|
+
*/
|
|
20
|
+
const defaultCreateUser = async () => {
|
|
21
|
+
throw new Error("xAuthLocal: createUser function not configured");
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default password reset - should be overridden
|
|
26
|
+
*/
|
|
27
|
+
const defaultPasswordReset = async () => {
|
|
28
|
+
throw new Error("xAuthLocal: passwordReset function not configured");
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create local auth routes plugin
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} options - Route options
|
|
35
|
+
* @param {Function} options.jwtService - JWT service instance
|
|
36
|
+
* @param {Function} [options.userLookup] - Function to lookup user by email
|
|
37
|
+
* @param {Function} [options.createUser] - Function to create new user
|
|
38
|
+
* @param {Function} [options.passwordReset] - Function to handle password reset
|
|
39
|
+
* @param {string} [options.prefix] - Route prefix (default: '/local')
|
|
40
|
+
* @param {number} [options.saltRounds] - bcrypt salt rounds (default: 10)
|
|
41
|
+
* @param {boolean} [options.skipUserLookup] - Skip userLookup for /me, use token data only
|
|
42
|
+
* @param {string} [options.requestProperty] - Property where auth is attached (default: 'auth')
|
|
43
|
+
* @returns {Function} Fastify plugin
|
|
44
|
+
*/
|
|
45
|
+
export function createLocalRoutes(options = {}) {
|
|
46
|
+
const {
|
|
47
|
+
jwtService,
|
|
48
|
+
userLookup = defaultUserLookup,
|
|
49
|
+
createUser = defaultCreateUser,
|
|
50
|
+
passwordReset = defaultPasswordReset,
|
|
51
|
+
saltRounds = 10,
|
|
52
|
+
skipUserLookup = false,
|
|
53
|
+
requestProperty = "auth",
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
if (!jwtService) {
|
|
57
|
+
throw new Error("xAuthLocal: jwtService is required for local routes");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return async function localRoutesPlugin(fastify) {
|
|
61
|
+
/**
|
|
62
|
+
* POST /local - Login
|
|
63
|
+
*
|
|
64
|
+
* Authenticates user with email/password and returns JWT token
|
|
65
|
+
*/
|
|
66
|
+
fastify.post(
|
|
67
|
+
"/",
|
|
68
|
+
{
|
|
69
|
+
schema: {
|
|
70
|
+
description: "Authenticate user and get JWT token",
|
|
71
|
+
tags: ["auth"],
|
|
72
|
+
body: {
|
|
73
|
+
type: "object",
|
|
74
|
+
required: ["email", "password"],
|
|
75
|
+
properties: {
|
|
76
|
+
email: { type: "string", format: "email" },
|
|
77
|
+
password: { type: "string", minLength: 1 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
response: {
|
|
81
|
+
200: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
token: { type: "string" },
|
|
85
|
+
user: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
id: { type: ["string", "integer"] },
|
|
89
|
+
email: { type: "string" },
|
|
90
|
+
first_name: { type: "string" },
|
|
91
|
+
last_name: { type: "string" },
|
|
92
|
+
admin: { type: "boolean" },
|
|
93
|
+
color: { type: "string" },
|
|
94
|
+
scope: {
|
|
95
|
+
oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
401: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
statusCode: { type: "integer" },
|
|
105
|
+
error: { type: "string" },
|
|
106
|
+
message: { type: "string" },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
async (request, reply) => {
|
|
113
|
+
const { email, password } = request.body;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Look up user by email
|
|
117
|
+
const user = await userLookup(email);
|
|
118
|
+
|
|
119
|
+
if (!user) {
|
|
120
|
+
return reply.code(401).send({
|
|
121
|
+
statusCode: 401,
|
|
122
|
+
error: "Unauthorized",
|
|
123
|
+
message: "Invalid email or password",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Verify password
|
|
128
|
+
const passwordValid = await bcrypt.compare(password, user.password);
|
|
129
|
+
|
|
130
|
+
if (!passwordValid) {
|
|
131
|
+
return reply.code(401).send({
|
|
132
|
+
statusCode: 401,
|
|
133
|
+
error: "Unauthorized",
|
|
134
|
+
message: "Invalid email or password",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Create JWT payload (excluding password)
|
|
139
|
+
const payload = {
|
|
140
|
+
id: user.id,
|
|
141
|
+
first_name: user.first_name,
|
|
142
|
+
last_name: user.last_name,
|
|
143
|
+
email: user.email,
|
|
144
|
+
admin: user.admin || false,
|
|
145
|
+
color: user.color,
|
|
146
|
+
scope: user.scope || user.roles || [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Sign token
|
|
150
|
+
const token = jwtService.sign(payload, {
|
|
151
|
+
subject: String(user.id),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Return token and user info (without password)
|
|
155
|
+
const { password: _, ...safeUser } = user;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
token,
|
|
159
|
+
user: {
|
|
160
|
+
id: safeUser.id,
|
|
161
|
+
email: safeUser.email,
|
|
162
|
+
first_name: safeUser.first_name,
|
|
163
|
+
last_name: safeUser.last_name,
|
|
164
|
+
admin: safeUser.admin || false,
|
|
165
|
+
color: safeUser.color,
|
|
166
|
+
scope: safeUser.scope || safeUser.roles || [],
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
request.log.error(error, "Login error");
|
|
171
|
+
return reply.code(500).send({
|
|
172
|
+
statusCode: 500,
|
|
173
|
+
error: "Internal Server Error",
|
|
174
|
+
message: "An error occurred during login",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* GET /local/me - Get current user
|
|
182
|
+
*
|
|
183
|
+
* Returns the current authenticated user's information
|
|
184
|
+
* Requires authentication (request[requestProperty] must be set)
|
|
185
|
+
* If skipUserLookup is true, returns data from token only
|
|
186
|
+
* Otherwise, can optionally fetch fresh user data from database
|
|
187
|
+
*/
|
|
188
|
+
fastify.get(
|
|
189
|
+
"/me",
|
|
190
|
+
{
|
|
191
|
+
schema: {
|
|
192
|
+
description: "Get current authenticated user",
|
|
193
|
+
tags: ["auth"],
|
|
194
|
+
response: {
|
|
195
|
+
200: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
id: { type: ["string", "integer"] },
|
|
199
|
+
email: { type: "string" },
|
|
200
|
+
first_name: { type: "string" },
|
|
201
|
+
last_name: { type: "string" },
|
|
202
|
+
admin: { type: "boolean" },
|
|
203
|
+
color: { type: "string" },
|
|
204
|
+
scope: {
|
|
205
|
+
oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
401: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
statusCode: { type: "integer" },
|
|
213
|
+
error: { type: "string" },
|
|
214
|
+
message: { type: "string" },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
async (request, reply) => {
|
|
221
|
+
// Auth middleware should have set request[requestProperty]
|
|
222
|
+
const auth = request[requestProperty];
|
|
223
|
+
|
|
224
|
+
if (!auth) {
|
|
225
|
+
return reply.code(401).send({
|
|
226
|
+
statusCode: 401,
|
|
227
|
+
error: "Unauthorized",
|
|
228
|
+
message: "Authentication required",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// If skipUserLookup is true, return data from token only
|
|
233
|
+
if (skipUserLookup) {
|
|
234
|
+
return {
|
|
235
|
+
id: auth.id,
|
|
236
|
+
email: auth.email,
|
|
237
|
+
first_name: auth.first_name,
|
|
238
|
+
last_name: auth.last_name,
|
|
239
|
+
admin: auth.admin || false,
|
|
240
|
+
color: auth.color,
|
|
241
|
+
scope: auth.scope || [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Otherwise, try to fetch fresh user data if userLookup is configured
|
|
246
|
+
try {
|
|
247
|
+
const user = await userLookup(auth.email);
|
|
248
|
+
|
|
249
|
+
if (user) {
|
|
250
|
+
return {
|
|
251
|
+
id: user.id,
|
|
252
|
+
email: user.email,
|
|
253
|
+
first_name: user.first_name,
|
|
254
|
+
last_name: user.last_name,
|
|
255
|
+
admin: user.admin || false,
|
|
256
|
+
color: user.color,
|
|
257
|
+
scope: user.scope || user.roles || [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// If userLookup fails, fall back to token data
|
|
262
|
+
request.log.debug(error, "userLookup failed, using token data");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Fall back to token data
|
|
266
|
+
return {
|
|
267
|
+
id: auth.id,
|
|
268
|
+
email: auth.email,
|
|
269
|
+
first_name: auth.first_name,
|
|
270
|
+
last_name: auth.last_name,
|
|
271
|
+
admin: auth.admin || false,
|
|
272
|
+
color: auth.color,
|
|
273
|
+
scope: auth.scope || [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* POST /register - Register new user
|
|
280
|
+
*
|
|
281
|
+
* Creates a new user account
|
|
282
|
+
*/
|
|
283
|
+
fastify.post(
|
|
284
|
+
"/register",
|
|
285
|
+
{
|
|
286
|
+
schema: {
|
|
287
|
+
description: "Register a new user account",
|
|
288
|
+
tags: ["auth"],
|
|
289
|
+
body: {
|
|
290
|
+
type: "object",
|
|
291
|
+
required: ["email", "password", "first_name", "last_name"],
|
|
292
|
+
properties: {
|
|
293
|
+
email: { type: "string", format: "email" },
|
|
294
|
+
password: { type: "string", minLength: 8 },
|
|
295
|
+
first_name: { type: "string", minLength: 1 },
|
|
296
|
+
last_name: { type: "string", minLength: 1 },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
response: {
|
|
300
|
+
201: {
|
|
301
|
+
type: "object",
|
|
302
|
+
properties: {
|
|
303
|
+
token: { type: "string" },
|
|
304
|
+
user: {
|
|
305
|
+
type: "object",
|
|
306
|
+
properties: {
|
|
307
|
+
id: { type: ["string", "integer"] },
|
|
308
|
+
email: { type: "string" },
|
|
309
|
+
first_name: { type: "string" },
|
|
310
|
+
last_name: { type: "string" },
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
400: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: {
|
|
318
|
+
statusCode: { type: "integer" },
|
|
319
|
+
error: { type: "string" },
|
|
320
|
+
message: { type: "string" },
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
409: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: {
|
|
326
|
+
statusCode: { type: "integer" },
|
|
327
|
+
error: { type: "string" },
|
|
328
|
+
message: { type: "string" },
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
async (request, reply) => {
|
|
335
|
+
const { email, password, first_name, last_name } = request.body;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// Check if user already exists
|
|
339
|
+
const existingUser = await userLookup(email);
|
|
340
|
+
|
|
341
|
+
if (existingUser) {
|
|
342
|
+
return reply.code(409).send({
|
|
343
|
+
statusCode: 409,
|
|
344
|
+
error: "Conflict",
|
|
345
|
+
message: "An account with this email already exists",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Hash password
|
|
350
|
+
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
351
|
+
|
|
352
|
+
// Create user
|
|
353
|
+
const newUser = await createUser({
|
|
354
|
+
email,
|
|
355
|
+
password: hashedPassword,
|
|
356
|
+
first_name,
|
|
357
|
+
last_name,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Create JWT payload
|
|
361
|
+
const payload = {
|
|
362
|
+
id: newUser.id,
|
|
363
|
+
first_name: newUser.first_name,
|
|
364
|
+
last_name: newUser.last_name,
|
|
365
|
+
email: newUser.email,
|
|
366
|
+
admin: newUser.admin || false,
|
|
367
|
+
scope: newUser.scope || newUser.roles || [],
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Sign token
|
|
371
|
+
const token = jwtService.sign(payload, {
|
|
372
|
+
subject: String(newUser.id),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return reply.code(201).send({
|
|
376
|
+
token,
|
|
377
|
+
user: {
|
|
378
|
+
id: newUser.id,
|
|
379
|
+
email: newUser.email,
|
|
380
|
+
first_name: newUser.first_name,
|
|
381
|
+
last_name: newUser.last_name,
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
} catch (error) {
|
|
385
|
+
request.log.error(error, "Registration error");
|
|
386
|
+
return reply.code(500).send({
|
|
387
|
+
statusCode: 500,
|
|
388
|
+
error: "Internal Server Error",
|
|
389
|
+
message: "An error occurred during registration",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* POST /password-reset - Request password reset
|
|
397
|
+
*
|
|
398
|
+
* Initiates password reset process
|
|
399
|
+
*/
|
|
400
|
+
fastify.post(
|
|
401
|
+
"/password-reset",
|
|
402
|
+
{
|
|
403
|
+
schema: {
|
|
404
|
+
description: "Request a password reset",
|
|
405
|
+
tags: ["auth"],
|
|
406
|
+
body: {
|
|
407
|
+
type: "object",
|
|
408
|
+
required: ["email"],
|
|
409
|
+
properties: {
|
|
410
|
+
email: { type: "string", format: "email" },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
response: {
|
|
414
|
+
200: {
|
|
415
|
+
type: "object",
|
|
416
|
+
properties: {
|
|
417
|
+
message: { type: "string" },
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
async (request, reply) => {
|
|
424
|
+
const { email } = request.body;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
// Call password reset handler
|
|
428
|
+
// The handler is responsible for sending email, creating tokens, etc.
|
|
429
|
+
await passwordReset(email);
|
|
430
|
+
|
|
431
|
+
// Always return success to prevent email enumeration
|
|
432
|
+
return {
|
|
433
|
+
message: "If an account with that email exists, a password reset link has been sent",
|
|
434
|
+
};
|
|
435
|
+
} catch (error) {
|
|
436
|
+
request.log.error(error, "Password reset error");
|
|
437
|
+
|
|
438
|
+
// Still return success to prevent email enumeration
|
|
439
|
+
return {
|
|
440
|
+
message: "If an account with that email exists, a password reset link has been sent",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* PUT /password-reset - Complete password reset
|
|
448
|
+
*
|
|
449
|
+
* Completes password reset with token and new password
|
|
450
|
+
*/
|
|
451
|
+
fastify.put(
|
|
452
|
+
"/password-reset",
|
|
453
|
+
{
|
|
454
|
+
schema: {
|
|
455
|
+
description: "Complete password reset with token",
|
|
456
|
+
tags: ["auth"],
|
|
457
|
+
body: {
|
|
458
|
+
type: "object",
|
|
459
|
+
required: ["token", "password"],
|
|
460
|
+
properties: {
|
|
461
|
+
token: { type: "string", minLength: 1 },
|
|
462
|
+
password: { type: "string", minLength: 8 },
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
response: {
|
|
466
|
+
200: {
|
|
467
|
+
type: "object",
|
|
468
|
+
properties: {
|
|
469
|
+
message: { type: "string" },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
400: {
|
|
473
|
+
type: "object",
|
|
474
|
+
properties: {
|
|
475
|
+
statusCode: { type: "integer" },
|
|
476
|
+
error: { type: "string" },
|
|
477
|
+
message: { type: "string" },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
async (request, reply) => {
|
|
484
|
+
const { token, password } = request.body;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
// Hash new password
|
|
488
|
+
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
489
|
+
|
|
490
|
+
// Call password reset complete handler
|
|
491
|
+
// The handler should validate token and update password
|
|
492
|
+
await passwordReset(null, token, hashedPassword);
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
message: "Password has been reset successfully",
|
|
496
|
+
};
|
|
497
|
+
} catch (error) {
|
|
498
|
+
request.log.error(error, "Password reset complete error");
|
|
499
|
+
|
|
500
|
+
return reply.code(400).send({
|
|
501
|
+
statusCode: 400,
|
|
502
|
+
error: "Bad Request",
|
|
503
|
+
message: error.message || "Invalid or expired reset token",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
);
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Hash a password using bcrypt
|
|
513
|
+
*
|
|
514
|
+
* @param {string} password - Plain text password
|
|
515
|
+
* @param {number} [rounds] - Salt rounds (default: 10)
|
|
516
|
+
* @returns {Promise<string>} Hashed password
|
|
517
|
+
*/
|
|
518
|
+
export async function hashPassword(password, rounds = 10) {
|
|
519
|
+
return bcrypt.hash(password, rounds);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Compare a password with a hash
|
|
524
|
+
*
|
|
525
|
+
* @param {string} password - Plain text password
|
|
526
|
+
* @param {string} hash - Hashed password
|
|
527
|
+
* @returns {Promise<boolean>} True if password matches
|
|
528
|
+
*/
|
|
529
|
+
export async function comparePassword(password, hash) {
|
|
530
|
+
return bcrypt.compare(password, hash);
|
|
531
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Service
|
|
3
|
+
*
|
|
4
|
+
* Handles JWT signing and verification with RS256 algorithm support.
|
|
5
|
+
* Compatible with express-jwt patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import jwt from "jsonwebtoken";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load a key from file or return the key if already a string
|
|
14
|
+
* @param {string} keyOrPath - Key content or file path
|
|
15
|
+
* @param {string} basePath - Base path for relative paths
|
|
16
|
+
* @returns {string} Key content
|
|
17
|
+
*/
|
|
18
|
+
function loadKey(keyOrPath, basePath = process.cwd()) {
|
|
19
|
+
if (!keyOrPath) return null;
|
|
20
|
+
|
|
21
|
+
// If it looks like a key (starts with -----BEGIN), return as-is
|
|
22
|
+
if (keyOrPath.includes("-----BEGIN")) {
|
|
23
|
+
return keyOrPath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Otherwise, try to load from file
|
|
27
|
+
try {
|
|
28
|
+
const keyPath = path.isAbsolute(keyOrPath) ? keyOrPath : path.join(basePath, keyOrPath);
|
|
29
|
+
return fs.readFileSync(keyPath, "utf8");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`Failed to load key from ${keyOrPath}: ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a JWT service instance
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} config - Configuration
|
|
39
|
+
* @param {string} [config.publicKey] - Public key content or path (for verification)
|
|
40
|
+
* @param {string} [config.privateKey] - Private key content or path (for signing)
|
|
41
|
+
* @param {string} [config.secret] - Symmetric secret (alternative to key pair)
|
|
42
|
+
* @param {string} [config.algorithm] - Algorithm (default: RS256 for keys, HS256 for secret)
|
|
43
|
+
* @param {string} [config.expiresIn] - Default token expiration (e.g., '4d', '1h')
|
|
44
|
+
* @param {string} [config.audience] - JWT audience claim
|
|
45
|
+
* @param {string} [config.issuer] - JWT issuer claim
|
|
46
|
+
* @param {string} [config.basePath] - Base path for relative key paths
|
|
47
|
+
* @returns {Object} JWT service
|
|
48
|
+
*/
|
|
49
|
+
export function createJwtService(config) {
|
|
50
|
+
const { publicKey, privateKey, secret, algorithm, expiresIn = "4d", audience, issuer, basePath = process.cwd() } = config;
|
|
51
|
+
|
|
52
|
+
// Load keys
|
|
53
|
+
const loadedPublicKey = publicKey ? loadKey(publicKey, basePath) : null;
|
|
54
|
+
const loadedPrivateKey = privateKey ? loadKey(privateKey, basePath) : null;
|
|
55
|
+
|
|
56
|
+
// Determine algorithm
|
|
57
|
+
const algo = algorithm || (loadedPrivateKey || loadedPublicKey ? "RS256" : "HS256");
|
|
58
|
+
|
|
59
|
+
// Determine signing key
|
|
60
|
+
const signingKey = loadedPrivateKey || secret;
|
|
61
|
+
const verifyKey = loadedPublicKey || secret;
|
|
62
|
+
|
|
63
|
+
if (!verifyKey) {
|
|
64
|
+
throw new Error("xAuth: Either publicKey/privateKey or secret is required");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sign a JWT token
|
|
69
|
+
*
|
|
70
|
+
* @param {Object} payload - Token payload
|
|
71
|
+
* @param {Object} [options] - Override options
|
|
72
|
+
* @returns {string} Signed JWT token
|
|
73
|
+
*/
|
|
74
|
+
function sign(payload, options = {}) {
|
|
75
|
+
if (!signingKey) {
|
|
76
|
+
throw new Error("xAuth: privateKey or secret required for signing");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const signOptions = {
|
|
80
|
+
algorithm: algo,
|
|
81
|
+
expiresIn: options.expiresIn || expiresIn,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Add audience if configured
|
|
85
|
+
if (audience || options.audience) {
|
|
86
|
+
signOptions.audience = options.audience || audience;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add issuer if configured
|
|
90
|
+
if (issuer || options.issuer) {
|
|
91
|
+
signOptions.issuer = options.issuer || issuer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Add subject if provided
|
|
95
|
+
if (options.subject) {
|
|
96
|
+
signOptions.subject = options.subject;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return jwt.sign(payload, signingKey, signOptions);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verify a JWT token
|
|
104
|
+
*
|
|
105
|
+
* @param {string} token - JWT token
|
|
106
|
+
* @param {Object} [options] - Verification options
|
|
107
|
+
* @returns {Object} Decoded token payload
|
|
108
|
+
*/
|
|
109
|
+
function verify(token, options = {}) {
|
|
110
|
+
const verifyOptions = {
|
|
111
|
+
algorithms: [algo],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Add audience verification if configured
|
|
115
|
+
if (audience || options.audience) {
|
|
116
|
+
verifyOptions.audience = options.audience || audience;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Add issuer verification if configured
|
|
120
|
+
if (issuer || options.issuer) {
|
|
121
|
+
verifyOptions.issuer = options.issuer || issuer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return jwt.verify(token, verifyKey, verifyOptions);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decode a JWT token without verification
|
|
129
|
+
*
|
|
130
|
+
* @param {string} token - JWT token
|
|
131
|
+
* @returns {Object|null} Decoded token or null
|
|
132
|
+
*/
|
|
133
|
+
function decode(token) {
|
|
134
|
+
return jwt.decode(token, { complete: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
sign,
|
|
139
|
+
verify,
|
|
140
|
+
decode,
|
|
141
|
+
algorithm: algo,
|
|
142
|
+
};
|
|
143
|
+
}
|