@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.
@@ -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
+ }