@javagt/express-easy-auth 1.0.1 → 1.0.3

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.1",
3
+ "version": "1.0.3",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
  }
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
  };
@@ -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();
@@ -302,14 +305,14 @@ router.post('/2fa/verify-setup', requireAuth, (req, res) => {
302
305
  const { code, token } = req.body;
303
306
  const submittedToken = token || code;
304
307
  const secret = req.session.pendingTotpSecret;
305
- 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' });
306
309
 
307
310
  const valid = verifySync({ token: submittedToken, secret, type: 'totp' });
308
- 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' });
309
312
 
310
313
  authDb.prepare('UPDATE users SET totp_secret=?, totp_enabled=1 WHERE id=?').run(secret, req.session.userId);
311
314
 
312
- generateRecoveryCodes(10).then(({ codes, hashes }) => {
315
+ generateRecoveryCodes(10).then(({ plain: codes, hashed: hashes }) => {
313
316
  const now = Date.now();
314
317
  const stmt = authDb.prepare('INSERT INTO recovery_codes (id, user_id, code_hash, created_at) VALUES (?, ?, ?, ?)');
315
318
  for (const hash of hashes) {
@@ -357,11 +360,11 @@ router.post('/passkeys/register/verify', requireAuth, async (req, res) => {
357
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')
358
361
  .get(req.userId, Date.now());
359
362
 
360
- 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' });
361
364
 
362
365
  try {
363
366
  const verification = await verifyRegistrationResponse({ response, expectedChallenge: challengeRow.challenge, expectedOrigin: origin, expectedRPID: rpID });
364
- 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' });
365
368
 
366
369
  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
367
370
  const now = Date.now();
@@ -407,14 +410,14 @@ router.post('/passkeys/authenticate/verify', async (req, res) => {
407
410
  const { rpID, origin } = getRpConfig(req);
408
411
  const { response } = req.body;
409
412
  const challenge = req.session.passkeyChallenge;
410
- 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' });
411
414
 
412
415
  const challengeRow = authDb.prepare('SELECT * FROM webauthn_challenges WHERE challenge = ? AND type = \'authentication\' AND expires_at > ? ORDER BY created_at DESC LIMIT 1')
413
416
  .get(challenge, Date.now());
414
- 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' });
415
418
 
416
419
  const passkey = authDb.prepare('SELECT * FROM passkeys WHERE credential_id = ?').get(response.id);
417
- 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' });
418
421
 
419
422
  try {
420
423
  const verification = await verifyAuthenticationResponse({
@@ -423,7 +426,7 @@ router.post('/passkeys/authenticate/verify', async (req, res) => {
423
426
  transports: passkey.transports ? JSON.parse(passkey.transports) : [] }
424
427
  });
425
428
 
426
- 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' });
427
430
 
428
431
  authDb.prepare('UPDATE passkeys SET counter = ?, last_used = ? WHERE id = ?').run(verification.authenticationInfo.newCounter, Date.now(), passkey.id);
429
432
  authDb.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeRow.id);