@lastshotlabs/bunshot 0.0.16 → 0.0.18

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.
Files changed (70) hide show
  1. package/README.md +322 -16
  2. package/dist/adapters/memoryAuth.d.ts +3 -0
  3. package/dist/adapters/memoryAuth.js +48 -2
  4. package/dist/adapters/mongoAuth.js +39 -1
  5. package/dist/adapters/sqliteAuth.d.ts +3 -0
  6. package/dist/adapters/sqliteAuth.js +53 -0
  7. package/dist/app.d.ts +45 -2
  8. package/dist/app.js +79 -4
  9. package/dist/index.d.ts +14 -7
  10. package/dist/index.js +8 -4
  11. package/dist/lib/appConfig.d.ts +35 -0
  12. package/dist/lib/appConfig.js +10 -0
  13. package/dist/lib/authAdapter.d.ts +24 -0
  14. package/dist/lib/authRateLimit.d.ts +2 -0
  15. package/dist/lib/authRateLimit.js +4 -0
  16. package/dist/lib/clientIp.d.ts +14 -0
  17. package/dist/lib/clientIp.js +52 -0
  18. package/dist/lib/constants.d.ts +2 -0
  19. package/dist/lib/constants.js +2 -0
  20. package/dist/lib/crypto.d.ts +11 -0
  21. package/dist/lib/crypto.js +22 -0
  22. package/dist/lib/emailVerification.d.ts +4 -0
  23. package/dist/lib/emailVerification.js +20 -12
  24. package/dist/lib/jwt.js +17 -4
  25. package/dist/lib/mfaChallenge.d.ts +23 -1
  26. package/dist/lib/mfaChallenge.js +151 -42
  27. package/dist/lib/oauth.d.ts +14 -1
  28. package/dist/lib/oauth.js +19 -1
  29. package/dist/lib/oauthCode.d.ts +15 -0
  30. package/dist/lib/oauthCode.js +90 -0
  31. package/dist/lib/resetPassword.js +12 -16
  32. package/dist/lib/session.js +6 -4
  33. package/dist/lib/ws.js +5 -1
  34. package/dist/middleware/bearerAuth.js +4 -3
  35. package/dist/middleware/botProtection.js +2 -2
  36. package/dist/middleware/cacheResponse.d.ts +1 -0
  37. package/dist/middleware/cacheResponse.js +14 -2
  38. package/dist/middleware/cors.d.ts +2 -0
  39. package/dist/middleware/cors.js +22 -8
  40. package/dist/middleware/csrf.d.ts +18 -0
  41. package/dist/middleware/csrf.js +115 -0
  42. package/dist/middleware/rateLimit.js +2 -3
  43. package/dist/models/AuthUser.d.ts +9 -0
  44. package/dist/models/AuthUser.js +9 -0
  45. package/dist/routes/auth.js +21 -9
  46. package/dist/routes/mfa.d.ts +5 -1
  47. package/dist/routes/mfa.js +221 -14
  48. package/dist/routes/oauth.js +274 -10
  49. package/dist/schemas/auth.d.ts +2 -0
  50. package/dist/schemas/auth.js +22 -1
  51. package/dist/server.d.ts +6 -0
  52. package/dist/server.js +10 -3
  53. package/dist/services/auth.d.ts +1 -0
  54. package/dist/services/auth.js +21 -5
  55. package/dist/services/mfa.d.ts +47 -0
  56. package/dist/services/mfa.js +276 -9
  57. package/dist/ws/index.js +3 -2
  58. package/docs/sections/auth-flow/full.md +180 -2
  59. package/docs/sections/configuration/full.md +20 -0
  60. package/docs/sections/configuration/overview.md +1 -1
  61. package/docs/sections/configuration-example/full.md +19 -1
  62. package/docs/sections/exports/full.md +11 -2
  63. package/docs/sections/multi-tenancy/full.md +5 -1
  64. package/docs/sections/oauth/full.md +80 -10
  65. package/docs/sections/oauth/overview.md +2 -2
  66. package/docs/sections/peer-dependencies/full.md +6 -2
  67. package/docs/sections/response-caching/full.md +3 -1
  68. package/docs/sections/websocket/full.md +4 -3
  69. package/docs/sections/websocket/overview.md +1 -1
  70. package/package.json +16 -4
@@ -1,9 +1,30 @@
1
1
  import { z } from "zod";
2
+ import { getPasswordPolicy } from "../lib/appConfig";
3
+ /** Build a Zod schema for the password field based on the configured policy.
4
+ * Applied to registration and reset-password. Login uses min(1) intentionally
5
+ * to avoid locking out users registered under older/weaker policies. */
6
+ const passwordSchema = () => {
7
+ const policy = getPasswordPolicy();
8
+ const minLen = policy.minLength ?? 8;
9
+ let schema = z.string().min(minLen, `Password must be at least ${minLen} characters`);
10
+ if (policy.requireLetter !== false) {
11
+ schema = schema.regex(/[a-zA-Z]/, "Password must contain at least one letter");
12
+ }
13
+ if (policy.requireDigit !== false) {
14
+ schema = schema.regex(/\d/, "Password must contain at least one digit");
15
+ }
16
+ if (policy.requireSpecial) {
17
+ schema = schema.regex(/[^a-zA-Z0-9]/, "Password must contain at least one special character");
18
+ }
19
+ return schema;
20
+ };
2
21
  export const makeRegisterSchema = (primaryField) => z.object({
3
22
  [primaryField]: primaryField === "email" ? z.string().email() : z.string().min(3),
4
- password: z.string().min(8),
23
+ password: passwordSchema(),
5
24
  });
6
25
  export const makeLoginSchema = (primaryField) => z.object({
7
26
  [primaryField]: primaryField === "email" ? z.string().email() : z.string().min(1),
8
27
  password: z.string().min(1),
9
28
  });
29
+ /** Password schema for reset-password — same policy as registration. */
30
+ export const resetPasswordSchema = () => passwordSchema();
package/dist/server.d.ts CHANGED
@@ -12,6 +12,12 @@ export interface WsConfig<T extends object = object> {
12
12
  * ws.data.userId is available for auth checks.
13
13
  */
14
14
  onRoomSubscribe?: (ws: ServerWebSocket<SocketData<T>>, room: string) => boolean | Promise<boolean>;
15
+ /**
16
+ * Maximum allowed WebSocket message size in bytes.
17
+ * Messages exceeding this limit will cause the connection to be closed with code 1009.
18
+ * Defaults to 65536 (64 KB).
19
+ */
20
+ maxMessageSize?: number;
15
21
  }
16
22
  export interface CreateServerConfig<T extends object = object> extends CreateAppConfig {
17
23
  port?: number;
package/dist/server.js CHANGED
@@ -6,16 +6,23 @@ export const createServer = async (config) => {
6
6
  const app = await createApp(config);
7
7
  const port = Number(process.env.PORT ?? config.port ?? 3000);
8
8
  const { workersDir, enableWorkers = true, ws: wsConfig = {} } = config;
9
- const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe } = wsConfig;
9
+ const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe, maxMessageSize = 65_536 } = wsConfig;
10
10
  const defaultOpen = defaultWebsocket.open;
11
- const defaultMessage = defaultWebsocket.message;
12
11
  const defaultClose = defaultWebsocket.close;
13
12
  const defaultDrain = defaultWebsocket.drain;
14
13
  const ws = {
15
14
  open: userWs?.open ?? defaultOpen,
16
15
  async message(socket, message) {
16
+ const size = typeof message === "string" ? message.length : message.byteLength;
17
+ if (size > maxMessageSize) {
18
+ socket.close(1009, "Message too large");
19
+ return;
20
+ }
17
21
  if (!await handleRoomActions(socket, message, onRoomSubscribe)) {
18
- (userWs?.message ?? defaultMessage)(socket, message);
22
+ if (userWs?.message) {
23
+ userWs.message(socket, message);
24
+ }
25
+ // No default echo — without a custom handler, non-room messages are silently dropped
19
26
  }
20
27
  },
21
28
  close(socket, code, reason) {
@@ -9,6 +9,7 @@ export interface AuthResult {
9
9
  mfaRequired?: boolean;
10
10
  mfaToken?: string;
11
11
  mfaMethods?: string[];
12
+ webauthnOptions?: Record<string, unknown>;
12
13
  }
13
14
  /** Create a session for a user (used internally and by MFA verify). */
14
15
  export declare const createSessionForUser: (userId: string, metadata?: SessionMetadata) => Promise<{
@@ -2,10 +2,10 @@ import { getAuthAdapter } from "../lib/authAdapter";
2
2
  import { HttpError } from "../lib/HttpError";
3
3
  import { signToken, verifyToken } from "../lib/jwt";
4
4
  import { createSession, deleteSession, getActiveSessionCount, evictOldestSession, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "../lib/session";
5
- import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig } from "../lib/appConfig";
5
+ import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig, getMfaWebAuthnConfig } from "../lib/appConfig";
6
6
  import { createVerificationToken } from "../lib/emailVerification";
7
7
  import { createMfaChallenge } from "../lib/mfaChallenge";
8
- import { generateEmailOtpCode } from "./mfa";
8
+ import { generateEmailOtpCode, generateWebAuthnAuthenticationOptions } from "./mfa";
9
9
  async function createSessionWithRefreshToken(userId, sessionId, metadata) {
10
10
  const rtConfig = getRefreshTokenConfig();
11
11
  const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
@@ -47,11 +47,16 @@ export const register = async (identifier, password, metadata) => {
47
47
  }
48
48
  return { token, userId: user.id, email: identifier, refreshToken };
49
49
  };
50
+ // Pre-computed dummy hash so non-existent-user login takes the same time as wrong-password login
51
+ const DUMMY_HASH = await Bun.password.hash("dummy-timing-safe-placeholder");
50
52
  export const login = async (identifier, password, metadata) => {
51
53
  const adapter = getAuthAdapter();
52
54
  const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
53
55
  const user = await findFn(identifier);
54
- if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
56
+ // Always verify against a hash to prevent timing-based user enumeration
57
+ const hashToVerify = user?.passwordHash ?? DUMMY_HASH;
58
+ const passwordValid = await Bun.password.verify(password, hashToVerify);
59
+ if (!user || !passwordValid) {
55
60
  throw new HttpError(401, "Invalid credentials");
56
61
  }
57
62
  // Check email verification before MFA to avoid leaking MFA status to unverified users
@@ -79,8 +84,19 @@ export const login = async (identifier, password, metadata) => {
79
84
  if (email)
80
85
  await emailOtpConfig.onSend(email, code);
81
86
  }
82
- const mfaToken = await createMfaChallenge(user.id, emailOtpHash);
83
- return { token: "", userId: user.id, mfaRequired: true, mfaToken, mfaMethods: methods };
87
+ // Generate WebAuthn authentication options if enabled
88
+ let webauthnChallenge;
89
+ let webauthnOptions;
90
+ const webauthnConfig = getMfaWebAuthnConfig();
91
+ if (methods.includes("webauthn") && webauthnConfig && adapter.getWebAuthnCredentials) {
92
+ const result = await generateWebAuthnAuthenticationOptions(user.id);
93
+ if (result) {
94
+ webauthnChallenge = result.challenge;
95
+ webauthnOptions = result.options;
96
+ }
97
+ }
98
+ const mfaToken = await createMfaChallenge(user.id, { emailOtpHash, webauthnChallenge });
99
+ return { token: "", userId: user.id, mfaRequired: true, mfaToken, mfaMethods: methods, webauthnOptions };
84
100
  }
85
101
  const sessionId = crypto.randomUUID();
86
102
  const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
@@ -35,3 +35,50 @@ export declare const disableEmailOtp: (userId: string, params: {
35
35
  }) => Promise<void>;
36
36
  /** Get the MFA methods enabled for a user. */
37
37
  export declare const getMfaMethods: (userId: string) => Promise<string[]>;
38
+ /**
39
+ * Eager startup check — call at route mount time to fail fast if the peer dependency is missing.
40
+ */
41
+ export declare const assertWebAuthnDependency: () => Promise<void>;
42
+ /**
43
+ * Generate WebAuthn authentication options for the login MFA flow.
44
+ * Called from auth.ts login when the user has "webauthn" in their methods.
45
+ */
46
+ export declare const generateWebAuthnAuthenticationOptions: (userId: string) => Promise<{
47
+ challenge: string;
48
+ options: Record<string, unknown>;
49
+ } | null>;
50
+ /**
51
+ * Initiate WebAuthn registration: generates registration options for the client.
52
+ * Returns options + a registration challenge token.
53
+ */
54
+ export declare const initiateWebAuthnRegistration: (userId: string) => Promise<{
55
+ options: Record<string, unknown>;
56
+ registrationToken: string;
57
+ }>;
58
+ /**
59
+ * Complete WebAuthn registration: verifies attestation and stores the credential.
60
+ * Returns recovery codes if this is the first MFA method.
61
+ */
62
+ export declare const completeWebAuthnRegistration: (userId: string, registrationToken: string, attestationResponse: any, name?: string) => Promise<{
63
+ credentialId: string;
64
+ recoveryCodes: string[] | null;
65
+ }>;
66
+ /**
67
+ * Verify a WebAuthn authentication assertion during login MFA.
68
+ */
69
+ export declare const verifyWebAuthn: (userId: string, assertionResponse: any, expectedChallenge: string) => Promise<boolean>;
70
+ /**
71
+ * Remove a single WebAuthn credential.
72
+ * Only requires identity verification when removing the last credential of the last MFA method.
73
+ */
74
+ export declare const removeWebAuthnCredential: (userId: string, credentialId: string, params: {
75
+ code?: string;
76
+ password?: string;
77
+ }) => Promise<void>;
78
+ /**
79
+ * Disable WebAuthn entirely: removes all credentials and the method.
80
+ */
81
+ export declare const disableWebAuthn: (userId: string, params: {
82
+ code?: string;
83
+ password?: string;
84
+ }) => Promise<void>;
@@ -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
- function sha256(input) {
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 === hashedInput);
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) === emailOtpHash;
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(ws, message) {
29
- ws.send(message);
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}`);