@lastshotlabs/bunshot 0.0.13 → 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 (123) hide show
  1. package/README.md +2816 -1747
  2. package/dist/adapters/memoryAuth.d.ts +7 -0
  3. package/dist/adapters/memoryAuth.js +177 -2
  4. package/dist/adapters/mongoAuth.js +94 -0
  5. package/dist/adapters/sqliteAuth.d.ts +9 -0
  6. package/dist/adapters/sqliteAuth.js +190 -2
  7. package/dist/app.d.ts +120 -2
  8. package/dist/app.js +104 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +24 -8
  12. package/dist/index.js +15 -5
  13. package/dist/lib/appConfig.d.ts +81 -0
  14. package/dist/lib/appConfig.js +30 -0
  15. package/dist/lib/authAdapter.d.ts +54 -0
  16. package/dist/lib/authRateLimit.d.ts +2 -0
  17. package/dist/lib/authRateLimit.js +4 -0
  18. package/dist/lib/clientIp.d.ts +14 -0
  19. package/dist/lib/clientIp.js +52 -0
  20. package/dist/lib/constants.d.ts +4 -0
  21. package/dist/lib/constants.js +4 -0
  22. package/dist/lib/context.d.ts +2 -0
  23. package/dist/lib/createDtoMapper.d.ts +33 -0
  24. package/dist/lib/createDtoMapper.js +69 -0
  25. package/dist/lib/crypto.d.ts +11 -0
  26. package/dist/lib/crypto.js +22 -0
  27. package/dist/lib/emailVerification.d.ts +4 -0
  28. package/dist/lib/emailVerification.js +20 -12
  29. package/dist/lib/jwt.d.ts +1 -1
  30. package/dist/lib/jwt.js +19 -6
  31. package/dist/lib/mfaChallenge.d.ts +42 -0
  32. package/dist/lib/mfaChallenge.js +293 -0
  33. package/dist/lib/oauth.d.ts +14 -1
  34. package/dist/lib/oauth.js +19 -1
  35. package/dist/lib/oauthCode.d.ts +15 -0
  36. package/dist/lib/oauthCode.js +90 -0
  37. package/dist/lib/queue.d.ts +33 -0
  38. package/dist/lib/queue.js +98 -0
  39. package/dist/lib/resetPassword.js +12 -16
  40. package/dist/lib/roles.d.ts +4 -0
  41. package/dist/lib/roles.js +27 -0
  42. package/dist/lib/session.d.ts +12 -0
  43. package/dist/lib/session.js +165 -5
  44. package/dist/lib/tenant.d.ts +15 -0
  45. package/dist/lib/tenant.js +65 -0
  46. package/dist/lib/ws.js +5 -1
  47. package/dist/lib/zodToMongoose.d.ts +38 -0
  48. package/dist/lib/zodToMongoose.js +84 -0
  49. package/dist/middleware/bearerAuth.js +4 -3
  50. package/dist/middleware/botProtection.js +2 -2
  51. package/dist/middleware/cacheResponse.d.ts +1 -0
  52. package/dist/middleware/cacheResponse.js +18 -3
  53. package/dist/middleware/cors.d.ts +2 -0
  54. package/dist/middleware/cors.js +22 -8
  55. package/dist/middleware/csrf.d.ts +18 -0
  56. package/dist/middleware/csrf.js +115 -0
  57. package/dist/middleware/rateLimit.d.ts +2 -1
  58. package/dist/middleware/rateLimit.js +7 -5
  59. package/dist/middleware/requireRole.d.ts +14 -3
  60. package/dist/middleware/requireRole.js +46 -6
  61. package/dist/middleware/tenant.d.ts +5 -0
  62. package/dist/middleware/tenant.js +116 -0
  63. package/dist/models/AuthUser.d.ts +17 -0
  64. package/dist/models/AuthUser.js +17 -0
  65. package/dist/models/TenantRole.d.ts +15 -0
  66. package/dist/models/TenantRole.js +23 -0
  67. package/dist/routes/auth.d.ts +5 -3
  68. package/dist/routes/auth.js +173 -30
  69. package/dist/routes/jobs.d.ts +2 -0
  70. package/dist/routes/jobs.js +270 -0
  71. package/dist/routes/mfa.d.ts +5 -0
  72. package/dist/routes/mfa.js +616 -0
  73. package/dist/routes/oauth.js +378 -23
  74. package/dist/schemas/auth.d.ts +2 -0
  75. package/dist/schemas/auth.js +22 -1
  76. package/dist/server.d.ts +6 -0
  77. package/dist/server.js +19 -3
  78. package/dist/services/auth.d.ts +18 -5
  79. package/dist/services/auth.js +112 -18
  80. package/dist/services/mfa.d.ts +84 -0
  81. package/dist/services/mfa.js +543 -0
  82. package/dist/ws/index.js +3 -2
  83. package/docs/sections/adding-middleware/full.md +35 -0
  84. package/docs/sections/adding-models/full.md +125 -0
  85. package/docs/sections/adding-models/overview.md +13 -0
  86. package/docs/sections/adding-routes/full.md +182 -0
  87. package/docs/sections/adding-routes/overview.md +23 -0
  88. package/docs/sections/auth-flow/full.md +634 -0
  89. package/docs/sections/auth-flow/overview.md +10 -0
  90. package/docs/sections/cli/full.md +30 -0
  91. package/docs/sections/configuration/full.md +155 -0
  92. package/docs/sections/configuration/overview.md +17 -0
  93. package/docs/sections/configuration-example/full.md +117 -0
  94. package/docs/sections/configuration-example/overview.md +30 -0
  95. package/docs/sections/documentation/full.md +171 -0
  96. package/docs/sections/environment-variables/full.md +55 -0
  97. package/docs/sections/exports/full.md +92 -0
  98. package/docs/sections/extending-context/full.md +59 -0
  99. package/docs/sections/header.md +3 -0
  100. package/docs/sections/installation/full.md +6 -0
  101. package/docs/sections/jobs/full.md +140 -0
  102. package/docs/sections/jobs/overview.md +15 -0
  103. package/docs/sections/mongodb-connections/full.md +45 -0
  104. package/docs/sections/mongodb-connections/overview.md +7 -0
  105. package/docs/sections/multi-tenancy/full.md +66 -0
  106. package/docs/sections/multi-tenancy/overview.md +15 -0
  107. package/docs/sections/oauth/full.md +189 -0
  108. package/docs/sections/oauth/overview.md +16 -0
  109. package/docs/sections/package-development/full.md +7 -0
  110. package/docs/sections/peer-dependencies/full.md +47 -0
  111. package/docs/sections/quick-start/full.md +43 -0
  112. package/docs/sections/response-caching/full.md +117 -0
  113. package/docs/sections/response-caching/overview.md +13 -0
  114. package/docs/sections/roles/full.md +136 -0
  115. package/docs/sections/roles/overview.md +12 -0
  116. package/docs/sections/running-without-redis/full.md +16 -0
  117. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  118. package/docs/sections/stack/full.md +10 -0
  119. package/docs/sections/websocket/full.md +101 -0
  120. package/docs/sections/websocket/overview.md +5 -0
  121. package/docs/sections/websocket-rooms/full.md +97 -0
  122. package/docs/sections/websocket-rooms/overview.md +5 -0
  123. package/package.json +30 -9
@@ -0,0 +1,543 @@
1
+ import { getAuthAdapter } from "../lib/authAdapter";
2
+ import { HttpError } from "../lib/HttpError";
3
+ import { getMfaIssuer, getMfaAlgorithm, getMfaDigits, getMfaPeriod, getMfaRecoveryCodeCount, getMfaEmailOtpConfig, getMfaEmailOtpCodeLength, getMfaWebAuthnConfig, getAppName } from "../lib/appConfig";
4
+ import { createMfaChallenge } from "../lib/mfaChallenge";
5
+ // Lazy-load otpauth to keep it as an optional peer dependency
6
+ let _otpauth = null;
7
+ async function getOtpAuth() {
8
+ if (!_otpauth)
9
+ _otpauth = await import("otpauth");
10
+ return _otpauth;
11
+ }
12
+ import { sha256, timingSafeEqual } from "../lib/crypto";
13
+ function generateRandomCode(length) {
14
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars: I/1/O/0
15
+ let code = "";
16
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
17
+ for (let i = 0; i < length; i++) {
18
+ code += chars[bytes[i] % chars.length];
19
+ }
20
+ return code;
21
+ }
22
+ function generateRecoveryCodes() {
23
+ const count = getMfaRecoveryCodeCount();
24
+ const plainCodes = [];
25
+ const hashedCodes = [];
26
+ for (let i = 0; i < count; i++) {
27
+ const plain = generateRandomCode(8);
28
+ plainCodes.push(plain);
29
+ hashedCodes.push(sha256(plain));
30
+ }
31
+ return { plainCodes, hashedCodes };
32
+ }
33
+ export const setupMfa = async (userId) => {
34
+ const adapter = getAuthAdapter();
35
+ if (!adapter.setMfaSecret)
36
+ throw new HttpError(501, "Auth adapter does not support MFA");
37
+ const otpauth = await getOtpAuth();
38
+ const secret = new otpauth.Secret();
39
+ const totp = new otpauth.TOTP({
40
+ issuer: getMfaIssuer(),
41
+ label: userId,
42
+ algorithm: getMfaAlgorithm(),
43
+ digits: getMfaDigits(),
44
+ period: getMfaPeriod(),
45
+ secret,
46
+ });
47
+ // Store the secret but don't enable MFA yet — user must confirm with a code
48
+ await adapter.setMfaSecret(userId, secret.base32);
49
+ return {
50
+ secret: secret.base32,
51
+ uri: totp.toString(),
52
+ };
53
+ };
54
+ export const verifySetup = async (userId, code) => {
55
+ const adapter = getAuthAdapter();
56
+ if (!adapter.getMfaSecret || !adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
57
+ throw new HttpError(501, "Auth adapter does not support MFA");
58
+ }
59
+ const secretStr = await adapter.getMfaSecret(userId);
60
+ if (!secretStr)
61
+ throw new HttpError(400, "MFA setup not initiated. Call POST /auth/mfa/setup first.");
62
+ const otpauth = await getOtpAuth();
63
+ const totp = new otpauth.TOTP({
64
+ issuer: getMfaIssuer(),
65
+ algorithm: getMfaAlgorithm(),
66
+ digits: getMfaDigits(),
67
+ period: getMfaPeriod(),
68
+ secret: otpauth.Secret.fromBase32(secretStr),
69
+ });
70
+ const delta = totp.validate({ token: code, window: 1 });
71
+ if (delta === null)
72
+ throw new HttpError(401, "Invalid TOTP code");
73
+ // Generate recovery codes (regenerates if enabling a second method)
74
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
75
+ await adapter.setRecoveryCodes(userId, hashedCodes);
76
+ await adapter.setMfaEnabled(userId, true);
77
+ // Add "totp" to mfaMethods
78
+ if (adapter.getMfaMethods && adapter.setMfaMethods) {
79
+ const methods = await adapter.getMfaMethods(userId);
80
+ if (!methods.includes("totp")) {
81
+ await adapter.setMfaMethods(userId, [...methods, "totp"]);
82
+ }
83
+ }
84
+ return plainCodes;
85
+ };
86
+ export const verifyTotp = async (userId, code) => {
87
+ const adapter = getAuthAdapter();
88
+ if (!adapter.getMfaSecret)
89
+ throw new HttpError(501, "Auth adapter does not support MFA");
90
+ const secretStr = await adapter.getMfaSecret(userId);
91
+ if (!secretStr)
92
+ return false;
93
+ const otpauth = await getOtpAuth();
94
+ const totp = new otpauth.TOTP({
95
+ issuer: getMfaIssuer(),
96
+ algorithm: getMfaAlgorithm(),
97
+ digits: getMfaDigits(),
98
+ period: getMfaPeriod(),
99
+ secret: otpauth.Secret.fromBase32(secretStr),
100
+ });
101
+ return totp.validate({ token: code, window: 1 }) !== null;
102
+ };
103
+ export const verifyRecoveryCode = async (userId, code) => {
104
+ const adapter = getAuthAdapter();
105
+ if (!adapter.getRecoveryCodes || !adapter.removeRecoveryCode)
106
+ return false;
107
+ const hashedCodes = await adapter.getRecoveryCodes(userId);
108
+ const hashedInput = sha256(code.toUpperCase());
109
+ const match = hashedCodes.find((h) => timingSafeEqual(h, hashedInput));
110
+ if (!match)
111
+ return false;
112
+ await adapter.removeRecoveryCode(userId, match);
113
+ return true;
114
+ };
115
+ export const disableMfa = async (userId, code) => {
116
+ const adapter = getAuthAdapter();
117
+ if (!adapter.setMfaEnabled || !adapter.setMfaSecret || !adapter.setRecoveryCodes) {
118
+ throw new HttpError(501, "Auth adapter does not support MFA");
119
+ }
120
+ const valid = await verifyTotp(userId, code);
121
+ if (!valid)
122
+ throw new HttpError(401, "Invalid TOTP code");
123
+ await adapter.setMfaEnabled(userId, false);
124
+ await adapter.setMfaSecret(userId, null);
125
+ await adapter.setRecoveryCodes(userId, []);
126
+ // Clear all mfaMethods
127
+ if (adapter.setMfaMethods) {
128
+ await adapter.setMfaMethods(userId, []);
129
+ }
130
+ };
131
+ export const regenerateRecoveryCodes = async (userId, code) => {
132
+ const adapter = getAuthAdapter();
133
+ if (!adapter.setRecoveryCodes)
134
+ throw new HttpError(501, "Auth adapter does not support MFA");
135
+ const valid = await verifyTotp(userId, code);
136
+ if (!valid)
137
+ throw new HttpError(401, "Invalid TOTP code");
138
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
139
+ await adapter.setRecoveryCodes(userId, hashedCodes);
140
+ return plainCodes;
141
+ };
142
+ // ---------------------------------------------------------------------------
143
+ // Email OTP
144
+ // ---------------------------------------------------------------------------
145
+ /** Generate a cryptographically random numeric OTP code. Returns { code, hash }. */
146
+ export const generateEmailOtpCode = (length) => {
147
+ const len = length ?? getMfaEmailOtpCodeLength();
148
+ const bytes = crypto.getRandomValues(new Uint8Array(len));
149
+ let code = "";
150
+ for (let i = 0; i < len; i++) {
151
+ code += (bytes[i] % 10).toString();
152
+ }
153
+ return { code, hash: sha256(code) };
154
+ };
155
+ /** Verify an email OTP code against a stored hash. */
156
+ export const verifyEmailOtp = (emailOtpHash, code) => {
157
+ return timingSafeEqual(sha256(code), emailOtpHash);
158
+ };
159
+ /**
160
+ * Initiate email OTP setup: sends a verification code to the user's email.
161
+ * Returns a setup challenge token that must be confirmed via confirmEmailOtp.
162
+ */
163
+ export const initiateEmailOtp = async (userId) => {
164
+ const adapter = getAuthAdapter();
165
+ const emailOtpConfig = getMfaEmailOtpConfig();
166
+ if (!emailOtpConfig)
167
+ throw new HttpError(501, "Email OTP is not configured");
168
+ const user = adapter.getUser ? await adapter.getUser(userId) : null;
169
+ if (!user?.email)
170
+ throw new HttpError(400, "No email address on account");
171
+ const { code, hash } = generateEmailOtpCode();
172
+ await emailOtpConfig.onSend(user.email, code);
173
+ // Store the hash in a challenge token for verification
174
+ const setupToken = await createMfaChallenge(userId, { emailOtpHash: hash });
175
+ return setupToken;
176
+ };
177
+ /**
178
+ * Confirm email OTP setup: verifies the code sent during initiateEmailOtp.
179
+ * Enables email OTP as an MFA method. Returns recovery codes if MFA was not previously active.
180
+ */
181
+ export const confirmEmailOtp = async (userId, setupToken, code) => {
182
+ const adapter = getAuthAdapter();
183
+ if (!adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
184
+ throw new HttpError(501, "Auth adapter does not support MFA");
185
+ }
186
+ // Import consumeMfaChallenge here to avoid circular dependency issues at module level
187
+ const { consumeMfaChallenge } = await import("../lib/mfaChallenge");
188
+ const challenge = await consumeMfaChallenge(setupToken);
189
+ if (!challenge)
190
+ throw new HttpError(401, "Invalid or expired setup token");
191
+ if (challenge.userId !== userId)
192
+ throw new HttpError(401, "Token does not match user");
193
+ if (!challenge.emailOtpHash)
194
+ throw new HttpError(400, "Invalid setup token — no OTP hash");
195
+ if (!verifyEmailOtp(challenge.emailOtpHash, code)) {
196
+ throw new HttpError(401, "Invalid verification code");
197
+ }
198
+ // Check if MFA was already active
199
+ const wasEnabled = adapter.isMfaEnabled ? await adapter.isMfaEnabled(userId) : false;
200
+ // Add "emailOtp" to mfaMethods
201
+ if (adapter.getMfaMethods && adapter.setMfaMethods) {
202
+ const methods = await adapter.getMfaMethods(userId);
203
+ if (!methods.includes("emailOtp")) {
204
+ await adapter.setMfaMethods(userId, [...methods, "emailOtp"]);
205
+ }
206
+ }
207
+ await adapter.setMfaEnabled(userId, true);
208
+ // Generate recovery codes if MFA was not previously active
209
+ if (!wasEnabled) {
210
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
211
+ await adapter.setRecoveryCodes(userId, hashedCodes);
212
+ return plainCodes;
213
+ }
214
+ // Regenerate recovery codes when adding a second method
215
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
216
+ await adapter.setRecoveryCodes(userId, hashedCodes);
217
+ return plainCodes;
218
+ };
219
+ /**
220
+ * Disable email OTP for a user.
221
+ * If TOTP is also enabled, requires a TOTP code. Otherwise requires password.
222
+ */
223
+ export const disableEmailOtp = async (userId, params) => {
224
+ const adapter = getAuthAdapter();
225
+ if (!adapter.setMfaEnabled)
226
+ throw new HttpError(501, "Auth adapter does not support MFA");
227
+ // Get current methods
228
+ const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : [];
229
+ const hasTotpEnabled = methods.includes("totp");
230
+ // Verify identity
231
+ if (hasTotpEnabled) {
232
+ if (!params.code)
233
+ throw new HttpError(400, "TOTP code required to disable email OTP");
234
+ const valid = await verifyTotp(userId, params.code);
235
+ if (!valid)
236
+ throw new HttpError(401, "Invalid TOTP code");
237
+ }
238
+ else {
239
+ if (!params.password)
240
+ throw new HttpError(400, "Password required to disable email OTP");
241
+ // Verify password — look up the user's hash and compare
242
+ const user = adapter.findByIdentifier
243
+ ? await adapter.findByIdentifier((await adapter.getUser?.(userId))?.email ?? "")
244
+ : await adapter.findByEmail((await adapter.getUser?.(userId))?.email ?? "");
245
+ if (!user)
246
+ throw new HttpError(404, "User not found");
247
+ const valid = await Bun.password.verify(params.password, user.passwordHash);
248
+ if (!valid)
249
+ throw new HttpError(401, "Invalid password");
250
+ }
251
+ // Remove "emailOtp" from methods
252
+ if (adapter.setMfaMethods) {
253
+ const updated = methods.filter((m) => m !== "emailOtp");
254
+ await adapter.setMfaMethods(userId, updated);
255
+ // If no methods remain, disable MFA entirely
256
+ if (updated.length === 0) {
257
+ await adapter.setMfaEnabled(userId, false);
258
+ if (adapter.setRecoveryCodes)
259
+ await adapter.setRecoveryCodes(userId, []);
260
+ }
261
+ }
262
+ };
263
+ /** Get the MFA methods enabled for a user. */
264
+ export const getMfaMethods = async (userId) => {
265
+ const adapter = getAuthAdapter();
266
+ if (adapter.getMfaMethods)
267
+ return adapter.getMfaMethods(userId);
268
+ // Backward compat
269
+ if (adapter.isMfaEnabled && await adapter.isMfaEnabled(userId))
270
+ return ["totp"];
271
+ return [];
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}`);
@@ -0,0 +1,35 @@
1
+ ## Adding Middleware
2
+
3
+ ### Global (runs on every request)
4
+
5
+ Pass via `middleware` config — injected after `identify`, before route matching:
6
+
7
+ ```ts
8
+ await createServer({
9
+ routesDir: import.meta.dir + "/routes",
10
+ app: { name: "My App", version: "1.0.0" },
11
+ middleware: [myMiddleware],
12
+ });
13
+ ```
14
+
15
+ Write it using core's exported types:
16
+
17
+ ```ts
18
+ // src/middleware/tenant.ts
19
+ import type { MiddlewareHandler } from "hono";
20
+ import type { AppEnv } from "@lastshotlabs/bunshot";
21
+
22
+ export const tenantMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
23
+ // c.get("userId") is available — identify has already run
24
+ await next();
25
+ };
26
+ ```
27
+
28
+ ### Per-route
29
+
30
+ ```ts
31
+ import { userAuth, rateLimit } from "@lastshotlabs/bunshot";
32
+
33
+ router.use("/admin", userAuth);
34
+ router.use("/admin", rateLimit({ windowMs: 60_000, max: 10 }));
35
+ ```