@javagt/express-easy-auth 1.0.0 → 1.0.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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "workbench.colorTheme": "Tomorrow Night Blue"
3
+ }
Binary file
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@javagt/express-easy-auth",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/readme.md CHANGED
@@ -106,6 +106,20 @@ The library provides a flexible logging system and explicit control over error e
106
106
  - **Custom Logger**: Plug in your own logger (e.g., Winston, Pino) by passing it to `setupAuth`.
107
107
  - [View Example: Custom Logger](examples/05-custom-logger.js)
108
108
 
109
+ ### 🌐 SPA Fallback (Modern Alternative to Wildcard Routes)
110
+ When building Single Page Applications (SPAs), avoid using catch-all wildcard routes (e.g., `app.get('*', ...)`) as they can cause routing conflicts and `PathError` in newer versions of Express.
111
+
112
+ Instead, use a middleware-based fallback that only triggers for HTML requests:
113
+ ```javascript
114
+ app.use((req, res, next) => {
115
+ if (req.method === 'GET' && req.accepts('html') && !req.path.startsWith('/api')) {
116
+ res.sendFile(path.join(__dirname, 'public/index.html'));
117
+ } else {
118
+ next();
119
+ }
120
+ });
121
+ ```
122
+
109
123
  ---
110
124
 
111
125
  ## 📜 API Reference
@@ -128,6 +142,8 @@ All identity endpoints are nested under the router (recommended path: `/api/v1/a
128
142
  | | POST | `/passkeys/authenticate/verify` | — | Verify passkey login |
129
143
  | | GET | `/passkeys` | ✓ | List registered passkeys |
130
144
  | | DELETE | `/passkeys/:id` | ✓ | Delete a passkey |
145
+ | **Account** | POST | `/password/change` | ✓ (Fresh) | Change current password |
146
+ | | POST | `/email/change` | ✓ (Fresh) | Update email address |
131
147
  | **Password Reset** | POST | `/password-reset/request` | — | Generate reset token |
132
148
  | | POST | `/password-reset/reset` | — | Complete reset |
133
149
  | **API Keys** | GET | `/api-keys` | ✓ | List user API keys |
package/src/client.js CHANGED
@@ -5,6 +5,16 @@
5
5
  * TOTP 2FA, and session management.
6
6
  */
7
7
 
8
+ export class AuthError extends Error {
9
+ constructor(message, status, code, data = {}) {
10
+ super(message || 'Authentication request failed');
11
+ this.name = 'AuthError';
12
+ this.status = status;
13
+ this.code = code;
14
+ this.data = data;
15
+ }
16
+ }
17
+
8
18
  export class AuthClient {
9
19
  /**
10
20
  * @param {Object} options
@@ -51,11 +61,7 @@ export class AuthClient {
51
61
 
52
62
  const data = await res.json().catch(() => ({}));
53
63
  if (!res.ok) {
54
- throw Object.assign(new Error(data.error || 'Request failed'), {
55
- code: data.code,
56
- status: res.status,
57
- data,
58
- });
64
+ throw new AuthError(data.error || 'Request failed', res.status, data.code, data);
59
65
  }
60
66
  return data;
61
67
  }
@@ -102,10 +108,17 @@ export class AuthClient {
102
108
  return this.request('/logout', { method: 'POST' });
103
109
  }
104
110
 
105
- async changePassword(newPassword, token) {
106
- return this.request('/change-password', {
111
+ async changePassword(newPassword) {
112
+ return this.request('/password/change', {
113
+ method: 'POST',
114
+ body: { newPassword }
115
+ });
116
+ }
117
+
118
+ async changeEmail(newEmail) {
119
+ return this.request('/email/change', {
107
120
  method: 'POST',
108
- body: { newPassword, token }
121
+ body: { newEmail }
109
122
  });
110
123
  }
111
124
 
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url';
7
7
  import { dirname } from 'path';
8
8
 
9
9
  import { DefaultLogger } from './utils/logger.js';
10
- import { AuthClient } from './client.js';
10
+ import { AuthClient, AuthError } from './client.js';
11
11
 
12
12
  /**
13
13
  * Initializes the authentication databases and configuration.
@@ -57,5 +57,6 @@ export {
57
57
  requireApiKey,
58
58
  authErrorLogger,
59
59
  DefaultLogger,
60
- AuthClient
60
+ AuthClient,
61
+ AuthError
61
62
  };
@@ -1,4 +1,4 @@
1
- import { randomUUID, randomBytes } from 'crypto';
1
+ import { randomUUID, randomBytes } from 'node:crypto';
2
2
  import { authDb } from '../db/init.js';
3
3
  import bcrypt from 'bcrypt';
4
4
 
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import bcrypt from 'bcrypt';
3
- import { randomUUID, randomBytes } from 'crypto';
3
+ import { randomUUID, randomBytes } from 'node:crypto';
4
4
  import { verifySync, generateSecret } from 'otplib';
5
5
  import QRCode from 'qrcode';
6
6
  import {
@@ -36,17 +36,17 @@ router.post('/register', async (req, res) => {
36
36
  if (!username || !email || !password) {
37
37
  return getAuthResponse(req, res, {
38
38
  status: 400,
39
- data: { error: 'username, email, and password are required' }
39
+ data: { error: 'username, email, and password are required', code: 'MISSING_CREDENTIALS' }
40
40
  });
41
41
  }
42
42
  const settings = getAppSettings();
43
43
  if (settings.auth_registration_enabled !== 'true') {
44
- return res.status(403).json({ error: 'Registration is currently disabled' });
44
+ return res.status(403).json({ error: 'Registration is currently disabled', code: 'REGISTRATION_DISABLED' });
45
45
  }
46
46
 
47
47
  const minLen = parseInt(settings.password_min_length || '8', 10);
48
48
  if (password.length < minLen) {
49
- return res.status(400).json({ error: `Password must be at least ${minLen} characters` });
49
+ return res.status(400).json({ error: `Password must be at least ${minLen} characters`, code: 'PASSWORD_TOO_SHORT' });
50
50
  }
51
51
 
52
52
  const db = authDb;
@@ -54,7 +54,7 @@ router.post('/register', async (req, res) => {
54
54
  if (existing) {
55
55
  return getAuthResponse(req, res, {
56
56
  status: 409,
57
- data: { error: 'Username or email already taken' }
57
+ data: { error: 'Username or email already taken', code: 'USER_EXISTS' }
58
58
  });
59
59
  }
60
60
 
@@ -87,7 +87,7 @@ router.post('/login', async (req, res) => {
87
87
  if (!username || !password) {
88
88
  return getAuthResponse(req, res, {
89
89
  status: 400,
90
- data: { error: 'username and password are required' }
90
+ data: { error: 'username and password are required', code: 'MISSING_CREDENTIALS' }
91
91
  });
92
92
  }
93
93
 
@@ -99,7 +99,7 @@ router.post('/login', async (req, res) => {
99
99
  await bcrypt.hash('dummy', SALT_ROUNDS);
100
100
  return getAuthResponse(req, res, {
101
101
  status: 401,
102
- data: { error: 'Invalid credentials' }
102
+ data: { error: 'Invalid credentials', code: 'INVALID_CREDENTIALS' }
103
103
  });
104
104
  }
105
105
 
@@ -108,7 +108,7 @@ router.post('/login', async (req, res) => {
108
108
  const minsLeft = Math.ceil((user.locked_until - now) / 60000);
109
109
  return getAuthResponse(req, res, {
110
110
  status: 403,
111
- data: { error: `Account locked. Please try again in ${minsLeft} minutes.` }
111
+ data: { error: `Account locked. Please try again in ${minsLeft} minutes.`, code: 'ACCOUNT_LOCKED' }
112
112
  });
113
113
  }
114
114
 
@@ -128,7 +128,10 @@ router.post('/login', async (req, res) => {
128
128
 
129
129
  return getAuthResponse(req, res, {
130
130
  status: 401,
131
- data: { error: failed >= maxAttempts ? `Account locked for ${lockoutMins} minutes` : 'Invalid credentials' }
131
+ data: {
132
+ error: failed >= maxAttempts ? `Account locked for ${lockoutMins} minutes` : 'Invalid credentials',
133
+ code: failed >= maxAttempts ? 'ACCOUNT_LOCKED' : 'INVALID_CREDENTIALS'
134
+ }
132
135
  });
133
136
  }
134
137
 
@@ -153,7 +156,7 @@ router.post('/login', async (req, res) => {
153
156
  if (!valid2FA?.valid) {
154
157
  return getAuthResponse(req, res, {
155
158
  status: 401,
156
- data: { error: 'Invalid 2FA code' }
159
+ data: { error: 'Invalid 2FA code', code: 'INVALID_2FA_CODE' }
157
160
  });
158
161
  }
159
162
  }
@@ -174,13 +177,13 @@ router.post('/login/recovery', async (req, res) => {
174
177
  if (!username) username = req.session.pendingUsername;
175
178
 
176
179
  if (!username || !code) {
177
- return getAuthResponse(req, res, { status: 400, data: { error: 'Username and recovery code are required' } });
180
+ return getAuthResponse(req, res, { status: 400, data: { error: 'Username and recovery code are required', code: 'MISSING_CREDENTIALS' } });
178
181
  }
179
182
 
180
183
  const db = authDb;
181
184
  const user = db.prepare('SELECT * FROM users WHERE username=? OR email=?').get(username || null, username || null);
182
185
  if (!user) {
183
- return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery attempt' } });
186
+ return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery attempt', code: 'INVALID_CREDENTIALS' } });
184
187
  }
185
188
 
186
189
  const codes = db.prepare('SELECT * FROM recovery_codes WHERE user_id=? AND used=0').all(user.id);
@@ -193,7 +196,7 @@ router.post('/login/recovery', async (req, res) => {
193
196
  }
194
197
 
195
198
  if (!matchedCodeId) {
196
- return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery code' } });
199
+ return getAuthResponse(req, res, { status: 401, data: { error: 'Invalid recovery code', code: 'INVALID_RECOVERY_CODE' } });
197
200
  }
198
201
 
199
202
  db.prepare('UPDATE recovery_codes SET used=1 WHERE id=?').run(matchedCodeId);
@@ -268,15 +271,15 @@ router.post(['/fresh-auth', '/reauth'], requireAuth, async (req, res) => {
268
271
 
269
272
  if (password) {
270
273
  const valid = await bcrypt.compare(password, user.password_hash);
271
- if (!valid) return res.status(401).json({ error: 'Invalid password' });
274
+ if (!valid) return res.status(401).json({ error: 'Invalid password', code: 'INVALID_PASSWORD' });
272
275
 
273
276
  if (user.totp_enabled) {
274
277
  if (!submittedToken) return res.json({ requires2FA: true });
275
278
  const ok = verifySync({ token: submittedToken, secret: user.totp_secret, type: 'totp' });
276
- if (!ok?.valid) return res.status(401).json({ error: 'Invalid TOTP code' });
279
+ if (!ok?.valid) return res.status(401).json({ error: 'Invalid TOTP code', code: 'INVALID_2FA_CODE' });
277
280
  }
278
281
  } else {
279
- return res.status(400).json({ error: 'password required for reauth' });
282
+ return res.status(400).json({ error: 'password required for reauth', code: 'MISSING_PASSWORD' });
280
283
  }
281
284
 
282
285
  req.session.lastAuthedAt = Date.now();
@@ -290,7 +293,9 @@ router.post('/2fa/setup', requireAuth, async (req, res) => {
290
293
  const secret = generateSecret();
291
294
  req.session.pendingTotpSecret = secret;
292
295
 
293
- const otpauthUrl = `otpauth://totp/${encodeURIComponent('AuthServer')}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=AuthServer`;
296
+ const { rpName } = getRpConfig(req);
297
+ const issuer = encodeURIComponent(rpName);
298
+ const otpauthUrl = `otpauth://totp/${issuer}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=${issuer}`;
294
299
  const qrCode = await QRCode.toDataURL(otpauthUrl);
295
300
 
296
301
  res.json({ secret, qrCode, otpauthUrl });
@@ -300,14 +305,14 @@ router.post('/2fa/verify-setup', requireAuth, (req, res) => {
300
305
  const { code, token } = req.body;
301
306
  const submittedToken = token || code;
302
307
  const secret = req.session.pendingTotpSecret;
303
- if (!secret) return res.status(400).json({ error: 'No pending TOTP setup' });
308
+ if (!secret) return res.status(400).json({ error: 'No pending TOTP setup', code: 'NO_PENDING_2FA' });
304
309
 
305
310
  const valid = verifySync({ token: submittedToken, secret, type: 'totp' });
306
- if (!valid?.valid) return res.status(401).json({ error: 'Invalid code' });
311
+ if (!valid?.valid) return res.status(401).json({ error: 'Invalid code', code: 'INVALID_2FA_CODE' });
307
312
 
308
313
  authDb.prepare('UPDATE users SET totp_secret=?, totp_enabled=1 WHERE id=?').run(secret, req.session.userId);
309
314
 
310
- generateRecoveryCodes(10).then(({ codes, hashes }) => {
315
+ generateRecoveryCodes(10).then(({ plain: codes, hashes }) => {
311
316
  const now = Date.now();
312
317
  const stmt = authDb.prepare('INSERT INTO recovery_codes (id, user_id, code_hash, created_at) VALUES (?, ?, ?, ?)');
313
318
  for (const hash of hashes) {
@@ -355,11 +360,11 @@ router.post('/passkeys/register/verify', requireAuth, async (req, res) => {
355
360
  const challengeRow = authDb.prepare('SELECT * FROM webauthn_challenges WHERE user_id = ? AND type = \'registration\' AND expires_at > ? ORDER BY created_at DESC LIMIT 1')
356
361
  .get(req.userId, Date.now());
357
362
 
358
- if (!challengeRow) return res.status(400).json({ error: 'No valid challenge found' });
363
+ if (!challengeRow) return res.status(400).json({ error: 'No valid challenge found', code: 'WEBAUTHN_CHALLENGE_NOT_FOUND' });
359
364
 
360
365
  try {
361
366
  const verification = await verifyRegistrationResponse({ response, expectedChallenge: challengeRow.challenge, expectedOrigin: origin, expectedRPID: rpID });
362
- if (!verification.verified || !verification.registrationInfo) return res.status(400).json({ error: 'Passkey registration failed' });
367
+ if (!verification.verified || !verification.registrationInfo) return res.status(400).json({ error: 'Passkey registration failed', code: 'PASSKEY_REGISTRATION_FAILED' });
363
368
 
364
369
  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
365
370
  const now = Date.now();
@@ -405,14 +410,14 @@ router.post('/passkeys/authenticate/verify', async (req, res) => {
405
410
  const { rpID, origin } = getRpConfig(req);
406
411
  const { response } = req.body;
407
412
  const challenge = req.session.passkeyChallenge;
408
- if (!challenge) return res.status(400).json({ error: 'No active passkey challenge' });
413
+ if (!challenge) return res.status(400).json({ error: 'No active passkey challenge', code: 'WEBAUTHN_CHALLENGE_NOT_FOUND' });
409
414
 
410
415
  const challengeRow = authDb.prepare('SELECT * FROM webauthn_challenges WHERE challenge = ? AND type = \'authentication\' AND expires_at > ? ORDER BY created_at DESC LIMIT 1')
411
416
  .get(challenge, Date.now());
412
- if (!challengeRow) return res.status(400).json({ error: 'Challenge expired or not found' });
417
+ if (!challengeRow) return res.status(400).json({ error: 'Challenge expired or not found', code: 'WEBAUTHN_CHALLENGE_EXPIRED' });
413
418
 
414
419
  const passkey = authDb.prepare('SELECT * FROM passkeys WHERE credential_id = ?').get(response.id);
415
- if (!passkey) return res.status(400).json({ error: 'Passkey not registered' });
420
+ if (!passkey) return res.status(400).json({ error: 'Passkey not registered', code: 'PASSKEY_NOT_FOUND' });
416
421
 
417
422
  try {
418
423
  const verification = await verifyAuthenticationResponse({
@@ -421,7 +426,7 @@ router.post('/passkeys/authenticate/verify', async (req, res) => {
421
426
  transports: passkey.transports ? JSON.parse(passkey.transports) : [] }
422
427
  });
423
428
 
424
- if (!verification.verified) return res.status(401).json({ error: 'Passkey verification failed' });
429
+ if (!verification.verified) return res.status(401).json({ error: 'Passkey verification failed', code: 'PASSKEY_VERIFICATION_FAILED' });
425
430
 
426
431
  authDb.prepare('UPDATE passkeys SET counter = ?, last_used = ? WHERE id = ?').run(verification.authenticationInfo.newCounter, Date.now(), passkey.id);
427
432
  authDb.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeRow.id);
@@ -449,6 +454,33 @@ router.delete('/passkeys/:id', requireAuth, (req, res) => {
449
454
  res.json({ success: true });
450
455
  });
451
456
 
457
+ // ─── ACCOUNT MANAGEMENT ──────────────────────────────────────────────────────
458
+
459
+ router.post('/password/change', requireFreshAuth, async (req, res) => {
460
+ const { newPassword } = req.body;
461
+ if (!newPassword) return res.status(400).json({ error: 'newPassword is required' });
462
+
463
+ const settings = getAppSettings();
464
+ const minLen = parseInt(settings.password_min_length || '8', 10);
465
+ if (newPassword.length < minLen) {
466
+ return res.status(400).json({ error: `Password must be at least ${minLen} characters` });
467
+ }
468
+
469
+ const hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
470
+ authDb.prepare('UPDATE users SET password_hash=?, updated_at=? WHERE id=?').run(hash, Date.now(), req.userId);
471
+
472
+ res.json({ success: true, message: 'Password changed successfully' });
473
+ });
474
+
475
+ router.post('/email/change', requireFreshAuth, async (req, res) => {
476
+ const { newEmail } = req.body;
477
+ if (!newEmail) return res.status(400).json({ error: 'newEmail is required' });
478
+
479
+ authDb.prepare('UPDATE users SET email=?, updated_at=? WHERE id=?').run(newEmail, Date.now(), req.userId);
480
+
481
+ res.json({ success: true, message: 'Email updated successfully', email: newEmail });
482
+ });
483
+
452
484
  // ─── SESSIONS ────────────────────────────────────────────────────────────────
453
485
 
454
486
  router.get('/sessions', requireAuth, (req, res) => {
@@ -1,5 +1,5 @@
1
1
  import bcrypt from 'bcrypt';
2
- import { randomBytes } from 'crypto';
2
+ import { randomBytes } from 'node:crypto';
3
3
 
4
4
  /**
5
5
  * Generate a set of secure, readable recovery codes.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { authDb } from '../db/init.js';
3
3
 
4
4
  /**