@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.
- package/.vscode/settings.json +3 -0
- package/data-test-v2/auth.db +0 -0
- package/data-test-v2/users.db +0 -0
- package/package.json +1 -1
- package/readme.md +16 -0
- package/src/client.js +21 -8
- package/src/index.js +3 -2
- package/src/middleware/auth.js +1 -1
- package/src/routes/auth.js +58 -26
- package/src/utils/authHelpers.js +1 -1
- package/src/utils/logger.js +1 -1
|
Binary file
|
|
File without changes
|
package/package.json
CHANGED
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
|
|
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
|
|
106
|
-
return this.request('/change
|
|
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: {
|
|
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
|
};
|
package/src/middleware/auth.js
CHANGED
package/src/routes/auth.js
CHANGED
|
@@ -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: {
|
|
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
|
|
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) => {
|
package/src/utils/authHelpers.js
CHANGED
package/src/utils/logger.js
CHANGED