@javagt/express-easy-auth 1.0.1 → 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/src/client.js +11 -5
- package/src/index.js +3 -2
- package/src/routes/auth.js +27 -24
|
Binary file
|
|
File without changes
|
package/package.json
CHANGED
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
|
}
|
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/routes/auth.js
CHANGED
|
@@ -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();
|
|
@@ -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, 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);
|