@nitronjs/framework 0.3.0 → 0.3.2

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/cli/create.js CHANGED
@@ -251,7 +251,8 @@ export default async function create(projectName, options = {}) {
251
251
  log(`${COLORS.red}Error: ${validation.error}${COLORS.reset}`);
252
252
  console.log();
253
253
  log(`${COLORS.dim}Run 'npx @nitronjs/framework --help' for usage information${COLORS.reset}`);
254
- process.exit(1);
254
+ process.exitCode = 1;
255
+ return;
255
256
  }
256
257
 
257
258
  const projectPath = path.resolve(process.cwd(), projectName);
@@ -260,13 +261,15 @@ export default async function create(projectName, options = {}) {
260
261
  log(`${COLORS.red}Error: Directory "${projectName}" already exists${COLORS.reset}`);
261
262
  console.log();
262
263
  log(`${COLORS.dim}Please choose a different project name or remove the existing directory${COLORS.reset}`);
263
- process.exit(1);
264
+ process.exitCode = 1;
265
+ return;
264
266
  }
265
267
 
266
268
  if (!fs.existsSync(SKELETON_DIR)) {
267
269
  log(`${COLORS.red}Error: Skeleton directory not found${COLORS.reset}`);
268
270
  log(`${COLORS.dim}This may indicate a corrupted installation. Try reinstalling NitronJS.${COLORS.reset}`);
269
- process.exit(1);
271
+ process.exitCode = 1;
272
+ return;
270
273
  }
271
274
 
272
275
  log(`${COLORS.bold}Creating project: ${COLORS.cyan}${projectName}${COLORS.reset}`);
package/cli/njs.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  const COLORS = {
4
4
  reset: "\x1b[0m",
@@ -61,25 +61,27 @@ const args = process.argv.slice(2);
61
61
 
62
62
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
63
63
  printHelp();
64
- process.exit(0);
64
+ }
65
+ else {
66
+ const command = args[0];
67
+ const additionalArgs = args.slice(1);
68
+
69
+ const KNOWN_COMMANDS = [
70
+ "dev", "start", "build",
71
+ "migrate", "migrate:rollback", "migrate:status", "migrate:fresh", "seed",
72
+ "storage:link",
73
+ "make:controller", "make:middleware", "make:model", "make:migration", "make:seeder"
74
+ ];
75
+
76
+ const isProjectName = command &&
77
+ !command.startsWith("-") &&
78
+ !KNOWN_COMMANDS.includes(command) &&
79
+ !command.startsWith("make:");
80
+
81
+ run(command, additionalArgs, isProjectName);
65
82
  }
66
83
 
67
- const command = args[0];
68
- const additionalArgs = args.slice(1);
69
-
70
- const KNOWN_COMMANDS = [
71
- "dev", "start", "build",
72
- "migrate", "migrate:rollback", "migrate:status", "migrate:fresh", "seed",
73
- "storage:link",
74
- "make:controller", "make:middleware", "make:model", "make:migration", "make:seeder"
75
- ];
76
-
77
- const isProjectName = command &&
78
- !command.startsWith("-") &&
79
- !KNOWN_COMMANDS.includes(command) &&
80
- !command.startsWith("make:");
81
-
82
- async function run() {
84
+ async function run(command, additionalArgs, isProjectName) {
83
85
  try {
84
86
  if (isProjectName) {
85
87
  const { default: create } = await import("./create.js");
@@ -166,7 +168,8 @@ async function run() {
166
168
  console.error(`${COLORS.red}Error: Name is required${COLORS.reset}`);
167
169
  console.log(`${COLORS.dim}Usage: njs ${command} <Name>${COLORS.reset}`);
168
170
  console.log(`${COLORS.dim}Example: njs ${command} ${type === "controller" ? "HomeController" : type === "middleware" ? "AuthMiddleware" : type === "model" ? "User" : type === "migration" ? "users" : "UserSeeder"}${COLORS.reset}`);
169
- process.exit(1);
171
+ process.exitCode = 1;
172
+ return;
170
173
  }
171
174
 
172
175
  const { default: Make } = await import("../lib/Console/Commands/MakeCommand.js");
@@ -177,7 +180,8 @@ async function run() {
177
180
  default:
178
181
  console.error(`${COLORS.red}Error: Unknown command '${command}'${COLORS.reset}`);
179
182
  console.log(`${COLORS.dim}Run 'njs --help' for available commands${COLORS.reset}`);
180
- process.exit(1);
183
+ process.exitCode = 1;
184
+ return;
181
185
  }
182
186
 
183
187
  if (exitCode !== null) {
@@ -185,7 +189,8 @@ async function run() {
185
189
  await checkForUpdates();
186
190
  process.exit(exitCode);
187
191
  }
188
- } catch (error) {
192
+ }
193
+ catch (error) {
189
194
  console.error(`${COLORS.red}Error: ${error.message}${COLORS.reset}`);
190
195
  if (process.env.DEBUG) {
191
196
  console.error(error.stack);
@@ -193,5 +198,3 @@ async function run() {
193
198
  process.exit(1);
194
199
  }
195
200
  }
196
-
197
- run();
package/lib/Auth/Auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import Hash from "../Hashing/Hash.js";
2
2
  import Config from "../Core/Config.js";
3
+ import Mfa from "./Mfa.js";
3
4
 
4
5
  /**
5
6
  * Authentication manager for handling user login, logout, and session management.
@@ -15,21 +16,25 @@ class Auth {
15
16
  server.decorateRequest("auth", null);
16
17
 
17
18
  server.addHook("onRequest", async (req, res) => {
19
+ req._response = res;
20
+
18
21
  req.auth = {
19
22
  guard: (guardName = null) => ({
20
23
  attempt: (credentials) => Auth.attempt(req, credentials, guardName),
21
24
  logout: () => Auth.logout(req, guardName),
22
25
  user: () => Auth.user(req, guardName),
23
26
  check: () => Auth.check(req, guardName),
24
- home: () => Auth.home(res, guardName),
25
- redirect: () => Auth.redirect(res, guardName)
27
+ home: () => Auth.home(req._response, guardName),
28
+ redirect: () => Auth.redirect(req._response, guardName),
29
+ mfa: new Mfa(req, guardName)
26
30
  }),
27
31
  attempt: (credentials) => Auth.attempt(req, credentials, null),
28
32
  logout: () => Auth.logout(req, null),
29
33
  user: () => Auth.user(req, null),
30
34
  check: () => Auth.check(req, null),
31
- home: () => Auth.home(res, null),
32
- redirect: () => Auth.redirect(res, null)
35
+ home: () => Auth.home(req._response, null),
36
+ redirect: () => Auth.redirect(req._response, null),
37
+ mfa: new Mfa(req, null)
33
38
  };
34
39
  });
35
40
  }
@@ -0,0 +1,518 @@
1
+ import crypto from "crypto";
2
+ import { TOTP, Secret } from "otpauth";
3
+ import QRCode from "qrcode";
4
+ import AES from "../Encryption/Encryption.js";
5
+ import Config from "../Core/Config.js";
6
+
7
+ /**
8
+ * Multi-Factor Authentication (MFA) manager for NitronJS.
9
+ * Provides TOTP-based two-factor authentication with recovery codes.
10
+ *
11
+ * This class is instantiated per-request via the Auth system.
12
+ * Access it through `req.auth.mfa` or `req.auth.guard("admin").mfa`.
13
+ *
14
+ * @example
15
+ * // Generate QR code for MFA setup
16
+ * const { qrCode, secret } = await req.auth.mfa.generate();
17
+ *
18
+ * // Confirm MFA setup with a code from authenticator app
19
+ * const result = await req.auth.mfa.confirmSetup(req.body.code);
20
+ *
21
+ * // Verify MFA code during login
22
+ * const verified = await req.auth.mfa.verify(req.body.code);
23
+ */
24
+ class Mfa {
25
+ /**
26
+ * Creates a new MFA instance for the current request and guard.
27
+ * This is called automatically by the Auth system — you don't need to create it manually.
28
+ *
29
+ * @param {import("fastify").FastifyRequest} req - Fastify request object
30
+ * @param {string|null} guardName - Guard name (e.g., "admin", "user") or null for default
31
+ */
32
+ constructor(req, guardName = null) {
33
+ const authConfig = Config.all("auth");
34
+
35
+ this._req = req;
36
+ this._guard = guardName || authConfig.defaults.guard;
37
+ this._guardConfig = authConfig.guards[this._guard];
38
+ }
39
+
40
+ /**
41
+ * Generates a new TOTP secret and QR code for MFA setup.
42
+ * The secret is temporarily stored in the session until confirmed with `confirmSetup()`.
43
+ *
44
+ * @param {Object} [options] - Optional settings for QR code generation
45
+ * @param {string} [options.issuer] - App name shown in authenticator (e.g., "My App"). Defaults to APP_NAME env variable.
46
+ * @param {string} [options.label] - User label shown in authenticator (e.g., email or username). Defaults to the guard identifier field.
47
+ * @returns {Promise<{qrCode: string, secret: string, otpauthUri: string}>}
48
+ * - `qrCode` — Base64 data URL PNG image, ready for `<img src="...">`
49
+ * - `secret` — Plain text secret (for manual entry in authenticator apps)
50
+ * - `otpauthUri` — otpauth:// URI used to generate the QR code
51
+ *
52
+ * @example
53
+ * // Basic usage (uses APP_NAME env and guard identifier)
54
+ * const { qrCode, secret } = await req.auth.mfa.generate();
55
+ *
56
+ * // With custom issuer and label
57
+ * const { qrCode, secret } = await req.auth.mfa.generate({
58
+ * issuer: "My App Name",
59
+ * label: user.email
60
+ * });
61
+ */
62
+ async generate(options = {}) {
63
+ const user = await this._getUser();
64
+ const appName = options.issuer || process.env.APP_NAME || "NitronJS App";
65
+ const identifier = this._guardConfig.identifier;
66
+ const label = options.label || user[identifier] || "user";
67
+
68
+ // Create a new random TOTP secret
69
+ const totpSecret = new Secret();
70
+
71
+ // Create the TOTP instance with app name and user label
72
+ const totp = new TOTP({
73
+ issuer: appName,
74
+ label: label,
75
+ algorithm: "SHA1",
76
+ digits: 6,
77
+ period: 30,
78
+ secret: totpSecret
79
+ });
80
+
81
+ // Generate the otpauth:// URI for QR code
82
+ const otpauthUri = totp.toString();
83
+
84
+ // Generate QR code as base64 data URL (PNG image)
85
+ const qrCode = await QRCode.toDataURL(otpauthUri, {
86
+ width: 256,
87
+ margin: 2
88
+ });
89
+
90
+ // Store the secret temporarily in session until user confirms with a valid code
91
+ const secretBase32 = totpSecret.base32;
92
+
93
+ this._req.session.set(`mfa_setup_secret_${this._guard}`, secretBase32);
94
+
95
+ return {
96
+ qrCode,
97
+ secret: secretBase32,
98
+ otpauthUri
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Confirms the MFA setup by verifying a TOTP code against the temporary secret.
104
+ * On success, encrypts and saves the secret to the user's `mfa` column,
105
+ * generates recovery codes, and clears the temporary session data.
106
+ *
107
+ * @param {string} code - 6-digit TOTP code from the authenticator app
108
+ * @returns {Promise<{success: boolean, recoveryCodes?: string[]}>}
109
+ * - On success: `{ success: true, recoveryCodes: ["XXXX-XXXX", ...] }` (8 codes)
110
+ * - On failure: `{ success: false }`
111
+ *
112
+ * @example
113
+ * const result = await req.auth.mfa.confirmSetup(req.body.code);
114
+ *
115
+ * if (result.success) {
116
+ * return res.view("mfa/recovery-codes", { codes: result.recoveryCodes });
117
+ * }
118
+ *
119
+ * req.session.flash("error", "Invalid code, please try again");
120
+ * return res.redirect(route("admin.mfa.setup"));
121
+ */
122
+ async confirmSetup(code) {
123
+ // Get the temporary secret that was stored during generate()
124
+ const tempSecret = this._req.session.get(`mfa_setup_secret_${this._guard}`);
125
+
126
+ if (!tempSecret) {
127
+ return { success: false };
128
+ }
129
+
130
+ // Verify the code against the temporary secret
131
+ const isValid = verifyTotpCode(tempSecret, code);
132
+
133
+ if (!isValid) {
134
+ return { success: false };
135
+ }
136
+
137
+ // Code is valid — save MFA data to the database
138
+ const user = await this._getUser();
139
+
140
+ // Generate 8 recovery codes in XXXX-XXXX format
141
+ const plainRecoveryCodes = generateRecoveryCodes(8);
142
+
143
+ // Hash each recovery code with HMAC-SHA256 for secure storage
144
+ const hashedRecoveryCodes = plainRecoveryCodes.map(
145
+ recoveryCode => hashRecoveryCode(recoveryCode)
146
+ );
147
+
148
+ // Build the MFA data object to store in the user's mfa column
149
+ const mfaData = {
150
+ secret: AES.encrypt(tempSecret),
151
+ recovery_codes: hashedRecoveryCodes,
152
+ confirmed_at: new Date().toISOString()
153
+ };
154
+
155
+ // Save to user model and clear temporary session data
156
+ user.mfa = JSON.stringify(mfaData);
157
+ await user.save();
158
+
159
+ this._req.session.set(`mfa_setup_secret_${this._guard}`, null);
160
+
161
+ return {
162
+ success: true,
163
+ recoveryCodes: plainRecoveryCodes
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Verifies a TOTP code during login.
169
+ * Reads the encrypted secret from the user's `mfa` column and validates the code.
170
+ * Allows ±1 time step (±30 seconds) for clock drift tolerance.
171
+ *
172
+ * @param {string} code - 6-digit TOTP code from the authenticator app
173
+ * @returns {Promise<boolean>} True if the code is valid
174
+ *
175
+ * @example
176
+ * const verified = await req.auth.mfa.verify(req.body.code);
177
+ *
178
+ * if (verified) {
179
+ * req.auth.mfa.clearPending();
180
+ * return res.redirect(route("admin.dashboard"));
181
+ * }
182
+ *
183
+ * req.session.flash("error", "Invalid code");
184
+ * return res.redirect(route("admin.mfa.verify"));
185
+ */
186
+ async verify(code) {
187
+ const mfaData = await this._getMfaData();
188
+
189
+ if (!mfaData || !mfaData.confirmed_at) {
190
+ return false;
191
+ }
192
+
193
+ // Decrypt the stored TOTP secret
194
+ const decryptedSecret = AES.decrypt(mfaData.secret);
195
+
196
+ if (!decryptedSecret) {
197
+ return false;
198
+ }
199
+
200
+ return verifyTotpCode(decryptedSecret, code);
201
+ }
202
+
203
+ /**
204
+ * Verifies a recovery code during login (when user lost access to authenticator app).
205
+ * Recovery codes are single-use — once verified, the code is removed from the database.
206
+ *
207
+ * @param {string} code - Recovery code in "XXXX-XXXX" format
208
+ * @returns {Promise<boolean>} True if the recovery code is valid
209
+ *
210
+ * @example
211
+ * const verified = await req.auth.mfa.verifyRecoveryCode(req.body.recovery_code);
212
+ *
213
+ * if (verified) {
214
+ * req.auth.mfa.clearPending();
215
+ * return res.redirect(route("admin.dashboard"));
216
+ * }
217
+ */
218
+ async verifyRecoveryCode(code) {
219
+ // Get user and parse MFA data in one query (avoid double DB call)
220
+ const user = await this._getUser();
221
+ const rawMfa = user.mfa;
222
+
223
+ if (!rawMfa) {
224
+ return false;
225
+ }
226
+
227
+ const mfaData = typeof rawMfa === "object" ? rawMfa : JSON.parse(rawMfa);
228
+
229
+ if (!mfaData.recovery_codes || mfaData.recovery_codes.length === 0) {
230
+ return false;
231
+ }
232
+
233
+ // Normalize the input: uppercase, trim whitespace
234
+ const normalizedCode = code.toUpperCase().trim();
235
+ const hashedInput = hashRecoveryCode(normalizedCode);
236
+
237
+ // Find the matching recovery code using timing-safe comparison
238
+ let matchIndex = -1;
239
+
240
+ for (let i = 0; i < mfaData.recovery_codes.length; i++) {
241
+ const storedHash = mfaData.recovery_codes[i];
242
+
243
+ if (timingSafeCompare(hashedInput, storedHash)) {
244
+ matchIndex = i;
245
+ break;
246
+ }
247
+ }
248
+
249
+ if (matchIndex === -1) {
250
+ return false;
251
+ }
252
+
253
+ // Remove the used recovery code from the array (single-use)
254
+ mfaData.recovery_codes.splice(matchIndex, 1);
255
+
256
+ // Save the updated MFA data back to the database
257
+ user.mfa = JSON.stringify(mfaData);
258
+ await user.save();
259
+
260
+ return true;
261
+ }
262
+
263
+ /**
264
+ * Disables MFA for the current user.
265
+ * Clears all MFA data (secret, recovery codes) from the database and session.
266
+ *
267
+ * Important: This method does NOT verify the user's password.
268
+ * You should handle confirmation (password check, etc.) in your controller before calling this.
269
+ *
270
+ * @returns {Promise<void>}
271
+ *
272
+ * @example
273
+ * // In your controller — verify password first, then disable
274
+ * const user = await req.auth.user();
275
+ * const passwordValid = await Hash.check(req.body.password, user.password);
276
+ *
277
+ * if (!passwordValid) {
278
+ * req.session.flash("error", "Invalid password");
279
+ * return res.redirect(route("admin.settings"));
280
+ * }
281
+ *
282
+ * await req.auth.mfa.disable();
283
+ */
284
+ async disable() {
285
+ const user = await this._getUser();
286
+
287
+ user.mfa = null;
288
+ await user.save();
289
+
290
+ // Clear any pending MFA state from the session
291
+ this._req.session.set(`mfa_pending_${this._guard}`, null);
292
+ this._req.session.set(`mfa_setup_secret_${this._guard}`, null);
293
+ }
294
+
295
+ /**
296
+ * Checks if the current user has MFA enabled (setup completed and confirmed).
297
+ *
298
+ * @returns {Promise<boolean>} True if MFA is enabled for this user
299
+ *
300
+ * @example
301
+ * const hasMfa = await req.auth.mfa.enabled();
302
+ *
303
+ * if (hasMfa) {
304
+ * req.auth.mfa.setPending();
305
+ * return res.redirect(route("admin.mfa.verify"));
306
+ * }
307
+ */
308
+ async enabled() {
309
+ const mfaData = await this._getMfaData();
310
+
311
+ if (!mfaData || !mfaData.confirmed_at) {
312
+ return false;
313
+ }
314
+
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Checks if the current session has a pending MFA verification.
320
+ * Use this in your middleware to block access until MFA is verified.
321
+ *
322
+ * @returns {boolean} True if MFA verification is pending
323
+ *
324
+ * @example
325
+ * // In your MFA middleware:
326
+ * if (req.auth.mfa.isPending()) {
327
+ * return res.redirect(route("admin.mfa.verify"));
328
+ * }
329
+ */
330
+ isPending() {
331
+ return this._req.session.get(`mfa_pending_${this._guard}`) === true;
332
+ }
333
+
334
+ /**
335
+ * Marks the current session as needing MFA verification.
336
+ * Call this after a successful `attempt()` when the user has MFA enabled.
337
+ *
338
+ * @returns {void}
339
+ *
340
+ * @example
341
+ * const success = await req.auth.attempt(credentials);
342
+ *
343
+ * if (success && await req.auth.mfa.enabled()) {
344
+ * req.auth.mfa.setPending();
345
+ * return res.redirect(route("admin.mfa.verify"));
346
+ * }
347
+ */
348
+ setPending() {
349
+ this._req.session.set(`mfa_pending_${this._guard}`, true);
350
+ }
351
+
352
+ /**
353
+ * Clears the MFA pending state from the session.
354
+ * Call this after a successful `verify()` or `verifyRecoveryCode()`.
355
+ *
356
+ * @returns {void}
357
+ *
358
+ * @example
359
+ * const verified = await req.auth.mfa.verify(req.body.code);
360
+ *
361
+ * if (verified) {
362
+ * req.auth.mfa.clearPending();
363
+ * return res.redirect(route("admin.dashboard"));
364
+ * }
365
+ */
366
+ clearPending() {
367
+ this._req.session.set(`mfa_pending_${this._guard}`, null);
368
+ }
369
+
370
+ /**
371
+ * Gets the current authenticated user for this guard.
372
+ * Internal helper used by other MFA methods.
373
+ *
374
+ * @returns {Promise<Object>} User model instance
375
+ * @throws {Error} If no user is authenticated
376
+ * @private
377
+ */
378
+ async _getUser() {
379
+ const authConfig = Config.all("auth");
380
+ const config = authConfig.guards[this._guard];
381
+ const userId = this._req.session.get(`auth_${this._guard}`);
382
+
383
+ if (!userId || !config) {
384
+ throw new Error("No authenticated user found for MFA operation");
385
+ }
386
+
387
+ return await config.provider.find(userId);
388
+ }
389
+
390
+ /**
391
+ * Reads and parses the MFA JSON data from the user's mfa column.
392
+ * Returns null if the user has no MFA data.
393
+ *
394
+ * @returns {Promise<Object|null>} Parsed MFA data or null
395
+ * @private
396
+ */
397
+ async _getMfaData() {
398
+ try {
399
+ const user = await this._getUser();
400
+ const rawMfa = user.mfa;
401
+
402
+ if (!rawMfa) {
403
+ return null;
404
+ }
405
+
406
+ // If it's already an object (some DB drivers auto-parse JSON), use it directly
407
+ if (typeof rawMfa === "object") {
408
+ return rawMfa;
409
+ }
410
+
411
+ return JSON.parse(rawMfa);
412
+ }
413
+ catch {
414
+ return null;
415
+ }
416
+ }
417
+ }
418
+
419
+ // ============================================================
420
+ // Helper functions (outside the class, module-level)
421
+ // ============================================================
422
+
423
+ /**
424
+ * Verifies a TOTP code against a base32 secret.
425
+ * Allows ±1 time step (±30 seconds) for clock drift tolerance.
426
+ *
427
+ * @param {string} secretBase32 - TOTP secret in base32 format
428
+ * @param {string} code - 6-digit code to verify
429
+ * @returns {boolean} True if the code is valid
430
+ */
431
+ function verifyTotpCode(secretBase32, code) {
432
+ const totp = new TOTP({
433
+ algorithm: "SHA1",
434
+ digits: 6,
435
+ period: 30,
436
+ secret: Secret.fromBase32(secretBase32)
437
+ });
438
+
439
+ // validate() returns the time step difference (0, -1, +1) if valid, or null if invalid
440
+ // window: 1 means we accept codes from 30 seconds ago and 30 seconds in the future
441
+ const result = totp.validate({
442
+ token: code,
443
+ window: 1
444
+ });
445
+
446
+ return result !== null;
447
+ }
448
+
449
+ /**
450
+ * Generates an array of random recovery codes in "XXXX-XXXX" format.
451
+ * Uses a safe character set that excludes ambiguous characters (0/O, 1/I/L).
452
+ *
453
+ * @param {number} count - Number of recovery codes to generate
454
+ * @returns {string[]} Array of plain text recovery codes
455
+ */
456
+ function generateRecoveryCodes(count) {
457
+ // Safe characters — no 0/O, 1/I/L confusion
458
+ const charset = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
459
+ const codes = [];
460
+
461
+ for (let i = 0; i < count; i++) {
462
+ let code = "";
463
+
464
+ // Generate 8 random characters (4 + dash + 4)
465
+ const randomBytes = crypto.randomBytes(8);
466
+
467
+ for (let j = 0; j < 8; j++) {
468
+ const index = randomBytes[j] % charset.length;
469
+
470
+ code += charset[index];
471
+ }
472
+
473
+ // Format as XXXX-XXXX
474
+ codes.push(code.slice(0, 4) + "-" + code.slice(4, 8));
475
+ }
476
+
477
+ return codes;
478
+ }
479
+
480
+ /**
481
+ * Hashes a recovery code using HMAC-SHA256 with APP_KEY.
482
+ * This is a one-way hash — the original code cannot be recovered.
483
+ *
484
+ * @param {string} code - Plain text recovery code
485
+ * @returns {string} Hex-encoded HMAC-SHA256 hash
486
+ */
487
+ function hashRecoveryCode(code) {
488
+ return crypto
489
+ .createHmac("sha256", process.env.APP_KEY)
490
+ .update(code)
491
+ .digest("hex");
492
+ }
493
+
494
+ /**
495
+ * Performs a timing-safe comparison of two hex strings.
496
+ * Prevents timing attacks when comparing recovery code hashes.
497
+ *
498
+ * @param {string} a - First hex string
499
+ * @param {string} b - Second hex string
500
+ * @returns {boolean} True if the strings are equal
501
+ */
502
+ function timingSafeCompare(a, b) {
503
+ if (a.length !== b.length) {
504
+ return false;
505
+ }
506
+
507
+ try {
508
+ return crypto.timingSafeEqual(
509
+ Buffer.from(a, "hex"),
510
+ Buffer.from(b, "hex")
511
+ );
512
+ }
513
+ catch {
514
+ return false;
515
+ }
516
+ }
517
+
518
+ export default Mfa;