@lastshotlabs/bunshot 0.0.16 → 0.0.19
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/README.md +322 -16
- package/dist/adapters/memoryAuth.d.ts +3 -0
- package/dist/adapters/memoryAuth.js +48 -2
- package/dist/adapters/mongoAuth.js +39 -1
- package/dist/adapters/sqliteAuth.d.ts +3 -0
- package/dist/adapters/sqliteAuth.js +53 -0
- package/dist/app.d.ts +45 -2
- package/dist/app.js +79 -4
- package/dist/index.d.ts +14 -7
- package/dist/index.js +8 -4
- package/dist/lib/appConfig.d.ts +35 -0
- package/dist/lib/appConfig.js +10 -0
- package/dist/lib/authAdapter.d.ts +24 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.js +17 -4
- package/dist/lib/mfaChallenge.d.ts +23 -1
- package/dist/lib/mfaChallenge.js +151 -42
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/session.js +6 -4
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +2 -2
- package/dist/lib/zodToMongoose.js +7 -3
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +14 -2
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.js +2 -3
- package/dist/models/AuthUser.d.ts +9 -0
- package/dist/models/AuthUser.js +9 -0
- package/dist/routes/auth.js +21 -9
- package/dist/routes/mfa.d.ts +5 -1
- package/dist/routes/mfa.js +221 -14
- package/dist/routes/oauth.js +274 -10
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -3
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.js +21 -5
- package/dist/services/mfa.d.ts +47 -0
- package/dist/services/mfa.js +276 -9
- package/dist/ws/index.js +3 -2
- package/docs/sections/auth-flow/full.md +180 -2
- package/docs/sections/configuration/full.md +20 -0
- package/docs/sections/configuration/overview.md +1 -1
- package/docs/sections/configuration-example/full.md +19 -1
- package/docs/sections/exports/full.md +11 -2
- package/docs/sections/multi-tenancy/full.md +5 -1
- package/docs/sections/oauth/full.md +80 -10
- package/docs/sections/oauth/overview.md +2 -2
- package/docs/sections/peer-dependencies/full.md +6 -2
- package/docs/sections/response-caching/full.md +3 -1
- package/docs/sections/websocket/full.md +4 -3
- package/docs/sections/websocket/overview.md +1 -1
- package/package.json +16 -4
package/dist/services/mfa.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
2
|
import { HttpError } from "../lib/HttpError";
|
|
3
|
-
import { getMfaIssuer, getMfaAlgorithm, getMfaDigits, getMfaPeriod, getMfaRecoveryCodeCount, getMfaEmailOtpConfig, getMfaEmailOtpCodeLength } from "../lib/appConfig";
|
|
3
|
+
import { getMfaIssuer, getMfaAlgorithm, getMfaDigits, getMfaPeriod, getMfaRecoveryCodeCount, getMfaEmailOtpConfig, getMfaEmailOtpCodeLength, getMfaWebAuthnConfig, getAppName } from "../lib/appConfig";
|
|
4
4
|
import { createMfaChallenge } from "../lib/mfaChallenge";
|
|
5
5
|
// Lazy-load otpauth to keep it as an optional peer dependency
|
|
6
6
|
let _otpauth = null;
|
|
@@ -9,11 +9,7 @@ async function getOtpAuth() {
|
|
|
9
9
|
_otpauth = await import("otpauth");
|
|
10
10
|
return _otpauth;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
const hash = new Bun.CryptoHasher("sha256");
|
|
14
|
-
hash.update(input);
|
|
15
|
-
return hash.digest("hex");
|
|
16
|
-
}
|
|
12
|
+
import { sha256, timingSafeEqual } from "../lib/crypto";
|
|
17
13
|
function generateRandomCode(length) {
|
|
18
14
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars: I/1/O/0
|
|
19
15
|
let code = "";
|
|
@@ -110,7 +106,7 @@ export const verifyRecoveryCode = async (userId, code) => {
|
|
|
110
106
|
return false;
|
|
111
107
|
const hashedCodes = await adapter.getRecoveryCodes(userId);
|
|
112
108
|
const hashedInput = sha256(code.toUpperCase());
|
|
113
|
-
const match = hashedCodes.find((h) => h
|
|
109
|
+
const match = hashedCodes.find((h) => timingSafeEqual(h, hashedInput));
|
|
114
110
|
if (!match)
|
|
115
111
|
return false;
|
|
116
112
|
await adapter.removeRecoveryCode(userId, match);
|
|
@@ -158,7 +154,7 @@ export const generateEmailOtpCode = (length) => {
|
|
|
158
154
|
};
|
|
159
155
|
/** Verify an email OTP code against a stored hash. */
|
|
160
156
|
export const verifyEmailOtp = (emailOtpHash, code) => {
|
|
161
|
-
return sha256(code)
|
|
157
|
+
return timingSafeEqual(sha256(code), emailOtpHash);
|
|
162
158
|
};
|
|
163
159
|
/**
|
|
164
160
|
* Initiate email OTP setup: sends a verification code to the user's email.
|
|
@@ -175,7 +171,7 @@ export const initiateEmailOtp = async (userId) => {
|
|
|
175
171
|
const { code, hash } = generateEmailOtpCode();
|
|
176
172
|
await emailOtpConfig.onSend(user.email, code);
|
|
177
173
|
// Store the hash in a challenge token for verification
|
|
178
|
-
const setupToken = await createMfaChallenge(userId, hash);
|
|
174
|
+
const setupToken = await createMfaChallenge(userId, { emailOtpHash: hash });
|
|
179
175
|
return setupToken;
|
|
180
176
|
};
|
|
181
177
|
/**
|
|
@@ -274,3 +270,274 @@ export const getMfaMethods = async (userId) => {
|
|
|
274
270
|
return ["totp"];
|
|
275
271
|
return [];
|
|
276
272
|
};
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// WebAuthn / FIDO2
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Lazy-load @simplewebauthn/server to keep it as an optional peer dependency
|
|
277
|
+
let _simplewebauthn = null;
|
|
278
|
+
async function getSimpleWebAuthn() {
|
|
279
|
+
if (!_simplewebauthn)
|
|
280
|
+
_simplewebauthn = await import("@simplewebauthn/server");
|
|
281
|
+
return _simplewebauthn;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Eager startup check — call at route mount time to fail fast if the peer dependency is missing.
|
|
285
|
+
*/
|
|
286
|
+
export const assertWebAuthnDependency = async () => {
|
|
287
|
+
try {
|
|
288
|
+
await import("@simplewebauthn/server");
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
throw new Error("@simplewebauthn/server is required when mfa.webauthn is configured. Install it: bun add @simplewebauthn/server");
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Generate WebAuthn authentication options for the login MFA flow.
|
|
296
|
+
* Called from auth.ts login when the user has "webauthn" in their methods.
|
|
297
|
+
*/
|
|
298
|
+
export const generateWebAuthnAuthenticationOptions = async (userId) => {
|
|
299
|
+
const config = getMfaWebAuthnConfig();
|
|
300
|
+
if (!config)
|
|
301
|
+
return null;
|
|
302
|
+
const adapter = getAuthAdapter();
|
|
303
|
+
if (!adapter.getWebAuthnCredentials)
|
|
304
|
+
return null;
|
|
305
|
+
const credentials = await adapter.getWebAuthnCredentials(userId);
|
|
306
|
+
if (credentials.length === 0)
|
|
307
|
+
return null;
|
|
308
|
+
const { generateAuthenticationOptions } = await getSimpleWebAuthn();
|
|
309
|
+
const options = await generateAuthenticationOptions({
|
|
310
|
+
rpID: config.rpId,
|
|
311
|
+
allowCredentials: credentials.map((c) => ({
|
|
312
|
+
id: c.credentialId,
|
|
313
|
+
transports: c.transports,
|
|
314
|
+
})),
|
|
315
|
+
userVerification: config.userVerification ?? "preferred",
|
|
316
|
+
timeout: config.timeout ?? 60000,
|
|
317
|
+
});
|
|
318
|
+
return { challenge: options.challenge, options: options };
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Initiate WebAuthn registration: generates registration options for the client.
|
|
322
|
+
* Returns options + a registration challenge token.
|
|
323
|
+
*/
|
|
324
|
+
export const initiateWebAuthnRegistration = async (userId) => {
|
|
325
|
+
const config = getMfaWebAuthnConfig();
|
|
326
|
+
if (!config)
|
|
327
|
+
throw new HttpError(501, "WebAuthn is not configured");
|
|
328
|
+
const adapter = getAuthAdapter();
|
|
329
|
+
if (!adapter.getWebAuthnCredentials)
|
|
330
|
+
throw new HttpError(501, "Auth adapter does not support WebAuthn");
|
|
331
|
+
const user = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
332
|
+
// Get existing credentials to exclude (prevent re-registration)
|
|
333
|
+
const existingCreds = await adapter.getWebAuthnCredentials(userId);
|
|
334
|
+
const { generateRegistrationOptions } = await getSimpleWebAuthn();
|
|
335
|
+
const options = await generateRegistrationOptions({
|
|
336
|
+
rpName: config.rpName ?? getAppName(),
|
|
337
|
+
rpID: config.rpId,
|
|
338
|
+
userName: user?.email ?? userId,
|
|
339
|
+
attestationType: config.attestationType ?? "none",
|
|
340
|
+
excludeCredentials: existingCreds.map((c) => ({
|
|
341
|
+
id: c.credentialId,
|
|
342
|
+
transports: c.transports,
|
|
343
|
+
})),
|
|
344
|
+
authenticatorSelection: {
|
|
345
|
+
authenticatorAttachment: config.authenticatorAttachment,
|
|
346
|
+
userVerification: config.userVerification ?? "preferred",
|
|
347
|
+
residentKey: "preferred",
|
|
348
|
+
},
|
|
349
|
+
timeout: config.timeout ?? 60000,
|
|
350
|
+
});
|
|
351
|
+
const { createWebAuthnRegistrationChallenge } = await import("../lib/mfaChallenge");
|
|
352
|
+
const registrationToken = await createWebAuthnRegistrationChallenge(userId, options.challenge);
|
|
353
|
+
return { options: options, registrationToken };
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Complete WebAuthn registration: verifies attestation and stores the credential.
|
|
357
|
+
* Returns recovery codes if this is the first MFA method.
|
|
358
|
+
*/
|
|
359
|
+
export const completeWebAuthnRegistration = async (userId, registrationToken, attestationResponse, name) => {
|
|
360
|
+
const config = getMfaWebAuthnConfig();
|
|
361
|
+
if (!config)
|
|
362
|
+
throw new HttpError(501, "WebAuthn is not configured");
|
|
363
|
+
const adapter = getAuthAdapter();
|
|
364
|
+
if (!adapter.addWebAuthnCredential || !adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
|
|
365
|
+
throw new HttpError(501, "Auth adapter does not support WebAuthn");
|
|
366
|
+
}
|
|
367
|
+
const { consumeWebAuthnRegistrationChallenge } = await import("../lib/mfaChallenge");
|
|
368
|
+
const challenge = await consumeWebAuthnRegistrationChallenge(registrationToken);
|
|
369
|
+
if (!challenge)
|
|
370
|
+
throw new HttpError(401, "Invalid or expired registration token");
|
|
371
|
+
if (challenge.userId !== userId)
|
|
372
|
+
throw new HttpError(401, "Token does not match user");
|
|
373
|
+
const { verifyRegistrationResponse } = await getSimpleWebAuthn();
|
|
374
|
+
const verification = await verifyRegistrationResponse({
|
|
375
|
+
response: attestationResponse,
|
|
376
|
+
expectedChallenge: challenge.challenge,
|
|
377
|
+
expectedOrigin: Array.isArray(config.origin) ? config.origin : [config.origin],
|
|
378
|
+
expectedRPID: config.rpId,
|
|
379
|
+
});
|
|
380
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
381
|
+
throw new HttpError(401, "WebAuthn registration verification failed");
|
|
382
|
+
}
|
|
383
|
+
const { credential } = verification.registrationInfo;
|
|
384
|
+
const credentialId = credential.id;
|
|
385
|
+
// Cross-user uniqueness check
|
|
386
|
+
if (adapter.findUserByWebAuthnCredentialId) {
|
|
387
|
+
const existingOwner = await adapter.findUserByWebAuthnCredentialId(credentialId);
|
|
388
|
+
if (existingOwner && existingOwner !== userId) {
|
|
389
|
+
throw new HttpError(409, "This security key is already registered to another account");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const newCredential = {
|
|
393
|
+
credentialId,
|
|
394
|
+
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
|
|
395
|
+
signCount: credential.counter,
|
|
396
|
+
transports: attestationResponse.response?.transports ?? [],
|
|
397
|
+
name: name ?? undefined,
|
|
398
|
+
createdAt: Date.now(),
|
|
399
|
+
};
|
|
400
|
+
await adapter.addWebAuthnCredential(userId, newCredential);
|
|
401
|
+
// Add "webauthn" to methods
|
|
402
|
+
if (adapter.getMfaMethods && adapter.setMfaMethods) {
|
|
403
|
+
const methods = await adapter.getMfaMethods(userId);
|
|
404
|
+
if (!methods.includes("webauthn")) {
|
|
405
|
+
await adapter.setMfaMethods(userId, [...methods, "webauthn"]);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Enable MFA + generate/regenerate recovery codes
|
|
409
|
+
await adapter.setMfaEnabled(userId, true);
|
|
410
|
+
const { plainCodes, hashedCodes } = generateRecoveryCodes();
|
|
411
|
+
await adapter.setRecoveryCodes(userId, hashedCodes);
|
|
412
|
+
return { credentialId, recoveryCodes: plainCodes };
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Verify a WebAuthn authentication assertion during login MFA.
|
|
416
|
+
*/
|
|
417
|
+
export const verifyWebAuthn = async (userId, assertionResponse, expectedChallenge) => {
|
|
418
|
+
const config = getMfaWebAuthnConfig();
|
|
419
|
+
if (!config)
|
|
420
|
+
return false;
|
|
421
|
+
const adapter = getAuthAdapter();
|
|
422
|
+
if (!adapter.getWebAuthnCredentials || !adapter.updateWebAuthnCredentialSignCount)
|
|
423
|
+
return false;
|
|
424
|
+
const credentials = await adapter.getWebAuthnCredentials(userId);
|
|
425
|
+
const credentialId = assertionResponse.id;
|
|
426
|
+
const matchedCred = credentials.find((c) => c.credentialId === credentialId);
|
|
427
|
+
if (!matchedCred)
|
|
428
|
+
return false;
|
|
429
|
+
const { verifyAuthenticationResponse } = await getSimpleWebAuthn();
|
|
430
|
+
try {
|
|
431
|
+
const verification = await verifyAuthenticationResponse({
|
|
432
|
+
response: assertionResponse,
|
|
433
|
+
expectedChallenge,
|
|
434
|
+
expectedOrigin: Array.isArray(config.origin) ? config.origin : [config.origin],
|
|
435
|
+
expectedRPID: config.rpId,
|
|
436
|
+
credential: {
|
|
437
|
+
id: matchedCred.credentialId,
|
|
438
|
+
publicKey: new Uint8Array(Buffer.from(matchedCred.publicKey, "base64url")),
|
|
439
|
+
counter: matchedCred.signCount,
|
|
440
|
+
transports: matchedCred.transports,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
if (!verification.verified)
|
|
444
|
+
return false;
|
|
445
|
+
const { authenticationInfo } = verification;
|
|
446
|
+
// Sign count policy
|
|
447
|
+
if (authenticationInfo.newCounter < matchedCred.signCount) {
|
|
448
|
+
if (config.strictSignCount) {
|
|
449
|
+
console.warn(`[webauthn] Sign count went backward for credential ${credentialId} (user ${userId}) — rejecting (strictSignCount enabled)`);
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
console.warn(`[webauthn] Sign count went backward for credential ${credentialId} (user ${userId}) — possible cloned authenticator`);
|
|
453
|
+
}
|
|
454
|
+
await adapter.updateWebAuthnCredentialSignCount(userId, credentialId, authenticationInfo.newCounter);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* Remove a single WebAuthn credential.
|
|
463
|
+
* Only requires identity verification when removing the last credential of the last MFA method.
|
|
464
|
+
*/
|
|
465
|
+
export const removeWebAuthnCredential = async (userId, credentialId, params) => {
|
|
466
|
+
const adapter = getAuthAdapter();
|
|
467
|
+
if (!adapter.getWebAuthnCredentials || !adapter.removeWebAuthnCredential) {
|
|
468
|
+
throw new HttpError(501, "Auth adapter does not support WebAuthn");
|
|
469
|
+
}
|
|
470
|
+
const credentials = await adapter.getWebAuthnCredentials(userId);
|
|
471
|
+
if (!credentials.some((c) => c.credentialId === credentialId)) {
|
|
472
|
+
throw new HttpError(404, "Credential not found");
|
|
473
|
+
}
|
|
474
|
+
const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : [];
|
|
475
|
+
const otherMethodsExist = methods.some((m) => m !== "webauthn");
|
|
476
|
+
const otherCredsExist = credentials.length > 1;
|
|
477
|
+
// Only require verification when removing the last credential of the last method
|
|
478
|
+
if (!otherMethodsExist && !otherCredsExist) {
|
|
479
|
+
await verifyIdentity(userId, params);
|
|
480
|
+
}
|
|
481
|
+
await adapter.removeWebAuthnCredential(userId, credentialId);
|
|
482
|
+
// If that was the last credential, remove "webauthn" from methods
|
|
483
|
+
if (!otherCredsExist && adapter.setMfaMethods) {
|
|
484
|
+
const updated = methods.filter((m) => m !== "webauthn");
|
|
485
|
+
await adapter.setMfaMethods(userId, updated);
|
|
486
|
+
// If no methods remain, disable MFA entirely
|
|
487
|
+
if (updated.length === 0 && adapter.setMfaEnabled) {
|
|
488
|
+
await adapter.setMfaEnabled(userId, false);
|
|
489
|
+
if (adapter.setRecoveryCodes)
|
|
490
|
+
await adapter.setRecoveryCodes(userId, []);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* Disable WebAuthn entirely: removes all credentials and the method.
|
|
496
|
+
*/
|
|
497
|
+
export const disableWebAuthn = async (userId, params) => {
|
|
498
|
+
const adapter = getAuthAdapter();
|
|
499
|
+
if (!adapter.getWebAuthnCredentials || !adapter.removeWebAuthnCredential) {
|
|
500
|
+
throw new HttpError(501, "Auth adapter does not support WebAuthn");
|
|
501
|
+
}
|
|
502
|
+
await verifyIdentity(userId, params);
|
|
503
|
+
const credentials = await adapter.getWebAuthnCredentials(userId);
|
|
504
|
+
for (const cred of credentials) {
|
|
505
|
+
await adapter.removeWebAuthnCredential(userId, cred.credentialId);
|
|
506
|
+
}
|
|
507
|
+
// Remove "webauthn" from methods
|
|
508
|
+
if (adapter.getMfaMethods && adapter.setMfaMethods) {
|
|
509
|
+
const methods = await adapter.getMfaMethods(userId);
|
|
510
|
+
const updated = methods.filter((m) => m !== "webauthn");
|
|
511
|
+
await adapter.setMfaMethods(userId, updated);
|
|
512
|
+
if (updated.length === 0 && adapter.setMfaEnabled) {
|
|
513
|
+
await adapter.setMfaEnabled(userId, false);
|
|
514
|
+
if (adapter.setRecoveryCodes)
|
|
515
|
+
await adapter.setRecoveryCodes(userId, []);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
/** Internal: verify identity via TOTP code or password. */
|
|
520
|
+
async function verifyIdentity(userId, params) {
|
|
521
|
+
const adapter = getAuthAdapter();
|
|
522
|
+
const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : [];
|
|
523
|
+
const hasTotpEnabled = methods.includes("totp");
|
|
524
|
+
if (hasTotpEnabled) {
|
|
525
|
+
if (!params.code)
|
|
526
|
+
throw new HttpError(400, "TOTP code required");
|
|
527
|
+
const valid = await verifyTotp(userId, params.code);
|
|
528
|
+
if (!valid)
|
|
529
|
+
throw new HttpError(401, "Invalid TOTP code");
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
if (!params.password)
|
|
533
|
+
throw new HttpError(400, "Password required");
|
|
534
|
+
const user = adapter.findByIdentifier
|
|
535
|
+
? await adapter.findByIdentifier((await adapter.getUser?.(userId))?.email ?? "")
|
|
536
|
+
: await adapter.findByEmail((await adapter.getUser?.(userId))?.email ?? "");
|
|
537
|
+
if (!user)
|
|
538
|
+
throw new HttpError(404, "User not found");
|
|
539
|
+
const valid = await Bun.password.verify(params.password, user.passwordHash);
|
|
540
|
+
if (!valid)
|
|
541
|
+
throw new HttpError(401, "Invalid password");
|
|
542
|
+
}
|
|
543
|
+
}
|
package/dist/ws/index.js
CHANGED
|
@@ -25,8 +25,9 @@ export const websocket = {
|
|
|
25
25
|
console.log(`[ws] connected: ${ws.data.id}`);
|
|
26
26
|
ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
|
|
27
27
|
},
|
|
28
|
-
message(
|
|
29
|
-
|
|
28
|
+
message(_ws, _message) {
|
|
29
|
+
// No-op: room actions are handled by server.ts via handleRoomActions.
|
|
30
|
+
// Override ws.handler.message in WsConfig for custom message handling.
|
|
30
31
|
},
|
|
31
32
|
close(ws) {
|
|
32
33
|
console.log(`[ws] disconnected: ${ws.data.id}`);
|
|
@@ -157,6 +157,69 @@ This two-step flow ensures the `onSend` callback actually delivers emails before
|
|
|
157
157
|
- If email OTP is the only method: requires the account password in the `password` field
|
|
158
158
|
- Disabling the last MFA method turns off MFA entirely
|
|
159
159
|
|
|
160
|
+
### WebAuthn / Security Keys
|
|
161
|
+
|
|
162
|
+
Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await createServer({
|
|
166
|
+
auth: {
|
|
167
|
+
mfa: {
|
|
168
|
+
webauthn: {
|
|
169
|
+
rpId: "example.com", // Relying Party ID — your domain
|
|
170
|
+
origin: "https://example.com", // Expected origin(s)
|
|
171
|
+
rpName: "My App", // Display name (default: app name)
|
|
172
|
+
userVerification: "preferred", // "required" | "preferred" | "discouraged"
|
|
173
|
+
timeout: 60000, // Ceremony timeout in ms (default: 60000)
|
|
174
|
+
strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Requires `@simplewebauthn/server` peer dependency:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
bun add @simplewebauthn/server
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.
|
|
188
|
+
|
|
189
|
+
#### Endpoints
|
|
190
|
+
|
|
191
|
+
| Endpoint | Auth | Purpose |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `POST /auth/mfa/webauthn/register-options` | userAuth | Generate registration options for `navigator.credentials.create()` |
|
|
194
|
+
| `POST /auth/mfa/webauthn/register` | userAuth | Verify attestation and store credential |
|
|
195
|
+
| `GET /auth/mfa/webauthn/credentials` | userAuth | List registered security keys |
|
|
196
|
+
| `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key |
|
|
197
|
+
| `DELETE /auth/mfa/webauthn` | userAuth | Disable WebAuthn entirely |
|
|
198
|
+
|
|
199
|
+
#### Registration flow
|
|
200
|
+
|
|
201
|
+
1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
|
|
202
|
+
2. Client passes `options` to `navigator.credentials.create()` — browser prompts user to tap/scan key
|
|
203
|
+
3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` → stores credential → returns recovery codes
|
|
204
|
+
|
|
205
|
+
#### Login flow with WebAuthn
|
|
206
|
+
|
|
207
|
+
1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
|
|
208
|
+
2. Client passes `webauthnOptions` to `navigator.credentials.get()` — browser prompts for key
|
|
209
|
+
3. `POST /auth/mfa/verify` with `{ mfaToken, webauthnResponse: {...} }` → creates session
|
|
210
|
+
|
|
211
|
+
The `webauthnOptions` object follows the WebAuthn spec — pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.
|
|
212
|
+
|
|
213
|
+
#### Credential removal
|
|
214
|
+
|
|
215
|
+
- Removing a spare key (other keys or MFA methods still active): no extra verification needed
|
|
216
|
+
- Removing the last credential of the last MFA method: requires TOTP code or password
|
|
217
|
+
- `DELETE /auth/mfa/webauthn` (disable all): always requires verification
|
|
218
|
+
|
|
219
|
+
#### Sign count validation
|
|
220
|
+
|
|
221
|
+
WebAuthn authenticators increment a sign count on each use to detect cloned keys. By default, a backward count logs a warning but allows authentication. Set `strictSignCount: true` to reject authentication when the count goes backward.
|
|
222
|
+
|
|
160
223
|
### Account Deletion
|
|
161
224
|
|
|
162
225
|
Enable `DELETE /auth/me` for user-initiated account deletion:
|
|
@@ -193,6 +256,25 @@ await createServer({
|
|
|
193
256
|
|
|
194
257
|
When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
|
|
195
258
|
|
|
259
|
+
### Password Policy
|
|
260
|
+
|
|
261
|
+
Configure password complexity requirements via `auth.passwordPolicy`. The policy applies to registration and password reset — login uses `min(1)` intentionally to avoid locking out users registered under older/weaker policies.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
await createServer({
|
|
265
|
+
auth: {
|
|
266
|
+
passwordPolicy: {
|
|
267
|
+
minLength: 10, // default: 8
|
|
268
|
+
requireLetter: true, // default: true — at least one a–z or A–Z
|
|
269
|
+
requireDigit: true, // default: true — at least one 0–9
|
|
270
|
+
requireSpecial: true, // default: false — at least one non-alphanumeric character
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
When not configured, the default policy requires 8+ characters with at least one letter and one digit.
|
|
277
|
+
|
|
196
278
|
### Protecting routes
|
|
197
279
|
|
|
198
280
|
```ts
|
|
@@ -297,6 +379,9 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
|
|
|
297
379
|
| `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
|
|
298
380
|
| `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
|
|
299
381
|
| `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
|
|
382
|
+
| `POST /auth/refresh` | IP address | Every attempt | 30 / min |
|
|
383
|
+
| `POST /auth/mfa/verify` | IP address | Every attempt | 10 / 15 min |
|
|
384
|
+
| `POST /auth/mfa/resend` | IP address | Every attempt | 5 / min |
|
|
300
385
|
|
|
301
386
|
Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
|
|
302
387
|
|
|
@@ -338,14 +423,14 @@ Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{user
|
|
|
338
423
|
`trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
|
|
339
424
|
|
|
340
425
|
```ts
|
|
341
|
-
import { trackAttempt, isLimited, bustAuthLimit } from "@lastshotlabs/bunshot";
|
|
426
|
+
import { trackAttempt, isLimited, bustAuthLimit, getClientIp } from "@lastshotlabs/bunshot";
|
|
342
427
|
|
|
343
428
|
// trackAttempt — increments the counter and returns true if now over the limit
|
|
344
429
|
// isLimited — checks without incrementing (read-only)
|
|
345
430
|
// bustAuthLimit — resets a key (e.g. on success or admin unlock)
|
|
346
431
|
|
|
347
432
|
router.post("/api/submit", async (c) => {
|
|
348
|
-
const ip = c
|
|
433
|
+
const ip = getClientIp(c);
|
|
349
434
|
const key = `submit:${ip}`;
|
|
350
435
|
|
|
351
436
|
if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
|
|
@@ -428,6 +513,49 @@ router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
|
|
|
428
513
|
|
|
429
514
|
---
|
|
430
515
|
|
|
516
|
+
### Trusted Proxy
|
|
517
|
+
|
|
518
|
+
By default, Bunshot uses the socket-level IP address for all rate limiting and session metadata — the `X-Forwarded-For` header is **ignored entirely**. This prevents attackers from spoofing IPs to bypass rate limits.
|
|
519
|
+
|
|
520
|
+
If your app runs behind a reverse proxy (nginx, Cloudflare, AWS ALB), configure `security.trustProxy` so the framework reads the real client IP from the `X-Forwarded-For` chain:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
await createServer({
|
|
524
|
+
security: {
|
|
525
|
+
trustProxy: 1, // trust 1 proxy hop — use the second-to-last IP in X-Forwarded-For
|
|
526
|
+
// trustProxy: 2, // behind 2 proxies (e.g. Cloudflare → ALB → app)
|
|
527
|
+
// trustProxy: false, // default — use socket IP, ignore XFF entirely
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
The number represents how many trusted proxy hops sit between your app and the internet. With `trustProxy: N`, the framework takes the Nth-from-right entry in the `X-Forwarded-For` chain, skipping the N trusted proxies.
|
|
533
|
+
|
|
534
|
+
All rate limiting (auth, general, bot protection) and session metadata (IP in `GET /auth/sessions`) use the centralized `getClientIp(c)` utility, which respects this setting. It's also exported for use in your own routes:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import { getClientIp } from "@lastshotlabs/bunshot";
|
|
538
|
+
|
|
539
|
+
router.post("/api/action", async (c) => {
|
|
540
|
+
const ip = getClientIp(c); // respects trustProxy setting
|
|
541
|
+
// ...
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### JWT Secret Validation
|
|
546
|
+
|
|
547
|
+
JWT secrets are validated on first use. The framework throws a clear error if:
|
|
548
|
+
- The environment variable (`JWT_SECRET_DEV` or `JWT_SECRET_PROD`) is missing
|
|
549
|
+
- The secret is shorter than 32 characters
|
|
550
|
+
|
|
551
|
+
Generate a strong secret:
|
|
552
|
+
|
|
553
|
+
```bash
|
|
554
|
+
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
431
559
|
### Setting a password after social login
|
|
432
560
|
|
|
433
561
|
If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
|
|
@@ -454,3 +582,53 @@ const myAdapter: AuthAdapter = {
|
|
|
454
582
|
},
|
|
455
583
|
};
|
|
456
584
|
```
|
|
585
|
+
|
|
586
|
+
### CSRF Protection
|
|
587
|
+
|
|
588
|
+
Opt-in via `security.csrf` — protects cookie-authenticated browser clients against cross-site request forgery attacks. Mobile apps and SPAs using header-based auth (`x-user-token`) are not affected and do not need CSRF.
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
await createServer({
|
|
592
|
+
security: {
|
|
593
|
+
csrf: {
|
|
594
|
+
enabled: true,
|
|
595
|
+
// exemptPaths: ["/webhooks/*"], // additional exempt paths
|
|
596
|
+
// checkOrigin: true, // validate Origin header (default: true)
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
**How it works:**
|
|
603
|
+
|
|
604
|
+
1. The first GET request sets a `csrf_token` cookie (non-HttpOnly, readable by JS)
|
|
605
|
+
2. The token is HMAC-SHA256 signed with the JWT secret to prevent forgery
|
|
606
|
+
3. For state-changing requests (POST/PUT/PATCH/DELETE), the client must send the cookie value back in the `x-csrf-token` header
|
|
607
|
+
4. The middleware validates the signature and compares the header to the cookie using timing-safe comparison
|
|
608
|
+
5. Requests without an auth cookie (`token`) skip validation — they are not vulnerable to CSRF
|
|
609
|
+
|
|
610
|
+
The CSRF cookie is refreshed on login, register, MFA verify, and OAuth exchange. It is cleared on logout.
|
|
611
|
+
|
|
612
|
+
**Client-side integration:**
|
|
613
|
+
|
|
614
|
+
```js
|
|
615
|
+
function getCsrfToken() {
|
|
616
|
+
return document.cookie
|
|
617
|
+
.split("; ")
|
|
618
|
+
.find(row => row.startsWith("csrf_token="))
|
|
619
|
+
?.split("=")[1];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Include on all state-changing requests
|
|
623
|
+
fetch("/api/resource", {
|
|
624
|
+
method: "POST",
|
|
625
|
+
credentials: "include",
|
|
626
|
+
headers: {
|
|
627
|
+
"Content-Type": "application/json",
|
|
628
|
+
"X-CSRF-Token": getCsrfToken(),
|
|
629
|
+
},
|
|
630
|
+
body: JSON.stringify(data),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// After login, read the NEW csrf_token value (it's refreshed on auth state changes)
|
|
634
|
+
```
|
|
@@ -43,6 +43,8 @@ await createServer({
|
|
|
43
43
|
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
44
44
|
forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
|
|
45
45
|
resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
46
|
+
mfaVerify: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
47
|
+
mfaResend: { windowMs: 60 * 1000, max: 5 }, // default: 5 attempts / minute (per IP)
|
|
46
48
|
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
47
49
|
},
|
|
48
50
|
sessionPolicy: { // optional — session concurrency and metadata
|
|
@@ -51,9 +53,16 @@ await createServer({
|
|
|
51
53
|
includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
|
|
52
54
|
trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
|
|
53
55
|
},
|
|
56
|
+
passwordPolicy: { // optional — password complexity rules (applies to register + reset, not login)
|
|
57
|
+
minLength: 8, // default: 8
|
|
58
|
+
requireLetter: true, // default: true — at least one a–z or A–Z
|
|
59
|
+
requireDigit: true, // default: true — at least one 0–9
|
|
60
|
+
requireSpecial: false, // default: false — at least one non-alphanumeric character
|
|
61
|
+
},
|
|
54
62
|
oauth: {
|
|
55
63
|
providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
|
|
56
64
|
postRedirect: "/dashboard", // default: "/"
|
|
65
|
+
allowedRedirectUrls: ["https://myapp.com"], // optional — validate postRedirect against allowlist at startup
|
|
57
66
|
},
|
|
58
67
|
refreshTokens: { // optional — short-lived access + long-lived refresh tokens
|
|
59
68
|
accessTokenExpiry: 900, // default: 900 (15 min)
|
|
@@ -104,6 +113,16 @@ await createServer({
|
|
|
104
113
|
fingerprintRateLimit: true, // rate-limit by HTTP fingerprint (IP-rotation resistant). default: false
|
|
105
114
|
blockList: ["198.51.100.0/24"], // IPv4 CIDRs or exact IPs to block with 403. default: []
|
|
106
115
|
},
|
|
116
|
+
headers: { // optional — additional security headers via Hono secureHeaders
|
|
117
|
+
contentSecurityPolicy: "default-src 'self'", // CSP header value
|
|
118
|
+
permissionsPolicy: "camera=(), microphone=()", // Permissions-Policy header value
|
|
119
|
+
},
|
|
120
|
+
trustProxy: 1, // default: false — see "Trusted Proxy" section below
|
|
121
|
+
csrf: { // opt-in CSRF protection for cookie-based auth
|
|
122
|
+
enabled: true, // default: false
|
|
123
|
+
exemptPaths: ["/webhooks/*"], // additional exempt paths (OAuth callbacks auto-exempt)
|
|
124
|
+
checkOrigin: true, // validate Origin header against CORS origins (default: true)
|
|
125
|
+
},
|
|
107
126
|
},
|
|
108
127
|
|
|
109
128
|
// Extra middleware injected after identify, before route matching
|
|
@@ -130,6 +149,7 @@ await createServer({
|
|
|
130
149
|
handler: { ... }, // override open/message/close/drain handlers
|
|
131
150
|
upgradeHandler: async (req, server) => { ... }, // replace default cookie-JWT upgrade logic
|
|
132
151
|
onRoomSubscribe(ws, room) { return true; }, // gate room subscriptions; can be async
|
|
152
|
+
maxMessageSize: 65_536, // default: 65536 (64 KB) — close connection on oversized messages
|
|
133
153
|
},
|
|
134
154
|
});
|
|
135
155
|
```
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| `app` | App name and version (shown in docs) |
|
|
9
9
|
| `auth` | Roles, OAuth, email verification, MFA, refresh tokens, rate limiting, account deletion |
|
|
10
10
|
| `db` | Connection and store routing — mongo, redis, sqlite, sessions, cache, auth adapter |
|
|
11
|
-
| `security` | CORS, bearer auth, rate limiting, bot protection |
|
|
11
|
+
| `security` | CORS, bearer auth, rate limiting, bot protection, CSRF |
|
|
12
12
|
| `tenancy` | Multi-tenant resolution (header/subdomain/path) |
|
|
13
13
|
| `jobs` | Job status REST endpoint config |
|
|
14
14
|
| `ws` | WebSocket handler and upgrade overrides |
|
|
@@ -48,6 +48,24 @@ const auth: AuthConfig = {
|
|
|
48
48
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
49
49
|
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/google/callback`,
|
|
50
50
|
},
|
|
51
|
+
apple: {
|
|
52
|
+
clientId: process.env.APPLE_CLIENT_ID!,
|
|
53
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
54
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
55
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!,
|
|
56
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/apple/callback`,
|
|
57
|
+
},
|
|
58
|
+
microsoft: {
|
|
59
|
+
tenantId: process.env.MICROSOFT_TENANT_ID!,
|
|
60
|
+
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
|
61
|
+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
|
62
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/microsoft/callback`,
|
|
63
|
+
},
|
|
64
|
+
github: {
|
|
65
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
66
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
67
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/github/callback`,
|
|
68
|
+
},
|
|
51
69
|
},
|
|
52
70
|
},
|
|
53
71
|
};
|
|
@@ -96,4 +114,4 @@ Every field above is optional except `routesDir`. See the [Configuration](#confi
|
|
|
96
114
|
| `GET /health` | Health check |
|
|
97
115
|
| `GET /docs` | Scalar API docs UI |
|
|
98
116
|
| `GET /openapi.json` | OpenAPI spec |
|
|
99
|
-
| `WS /ws` | WebSocket endpoint (cookie-JWT auth) |
|
|
117
|
+
| `WS /ws` | WebSocket endpoint (cookie-JWT auth) |
|