@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.
Files changed (72) 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/lib/zodToMongoose.d.ts +2 -2
  35. package/dist/lib/zodToMongoose.js +7 -3
  36. package/dist/middleware/bearerAuth.js +4 -3
  37. package/dist/middleware/botProtection.js +2 -2
  38. package/dist/middleware/cacheResponse.d.ts +1 -0
  39. package/dist/middleware/cacheResponse.js +14 -2
  40. package/dist/middleware/cors.d.ts +2 -0
  41. package/dist/middleware/cors.js +22 -8
  42. package/dist/middleware/csrf.d.ts +18 -0
  43. package/dist/middleware/csrf.js +115 -0
  44. package/dist/middleware/rateLimit.js +2 -3
  45. package/dist/models/AuthUser.d.ts +9 -0
  46. package/dist/models/AuthUser.js +9 -0
  47. package/dist/routes/auth.js +21 -9
  48. package/dist/routes/mfa.d.ts +5 -1
  49. package/dist/routes/mfa.js +221 -14
  50. package/dist/routes/oauth.js +274 -10
  51. package/dist/schemas/auth.d.ts +2 -0
  52. package/dist/schemas/auth.js +22 -1
  53. package/dist/server.d.ts +6 -0
  54. package/dist/server.js +10 -3
  55. package/dist/services/auth.d.ts +1 -0
  56. package/dist/services/auth.js +21 -5
  57. package/dist/services/mfa.d.ts +47 -0
  58. package/dist/services/mfa.js +276 -9
  59. package/dist/ws/index.js +3 -2
  60. package/docs/sections/auth-flow/full.md +180 -2
  61. package/docs/sections/configuration/full.md +20 -0
  62. package/docs/sections/configuration/overview.md +1 -1
  63. package/docs/sections/configuration-example/full.md +19 -1
  64. package/docs/sections/exports/full.md +11 -2
  65. package/docs/sections/multi-tenancy/full.md +5 -1
  66. package/docs/sections/oauth/full.md +80 -10
  67. package/docs/sections/oauth/overview.md +2 -2
  68. package/docs/sections/peer-dependencies/full.md +6 -2
  69. package/docs/sections/response-caching/full.md +3 -1
  70. package/docs/sections/websocket/full.md +4 -3
  71. package/docs/sections/websocket/overview.md +1 -1
  72. package/package.json +16 -4
@@ -2,7 +2,7 @@ import { createRoute, withSecurity } from "../lib/createRoute";
2
2
  import { z } from "zod";
3
3
  import { setCookie, getCookie, deleteCookie } from "hono/cookie";
4
4
  import * as AuthService from "../services/auth";
5
- import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
5
+ import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
6
6
  import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
7
7
  import { userAuth } from "../middleware/userAuth";
8
8
  import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
@@ -10,8 +10,10 @@ import { getAuthAdapter } from "../lib/authAdapter";
10
10
  import { createRouter } from "../lib/context";
11
11
  import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
12
12
  import { createResetToken, consumeResetToken } from "../lib/resetPassword";
13
- import { getRefreshTokenExpiry, getAccessTokenExpiry } from "../lib/appConfig";
13
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
14
+ import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
14
15
  import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
16
+ import { getClientIp } from "../lib/clientIp";
15
17
  const isProd = process.env.NODE_ENV === "production";
16
18
  const TokenResponse = z.object({
17
19
  token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
@@ -33,7 +35,6 @@ const cookieOptions = (maxAge) => ({
33
35
  path: "/",
34
36
  maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
35
37
  });
36
- const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
37
38
  export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
38
39
  const router = createRouter();
39
40
  const RegisterSchema = makeRegisterSchema(primaryField);
@@ -61,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
61
62
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
62
63
  },
63
64
  }), async (c) => {
64
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
65
+ const ip = getClientIp(c);
65
66
  if (await trackAttempt(`register:${ip}`, registerOpts)) {
66
67
  return c.json({ error: "Too many registration attempts. Try again later." }, 429);
67
68
  }
@@ -76,6 +77,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
76
77
  if (result.refreshToken) {
77
78
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
78
79
  }
80
+ if (getCsrfEnabled())
81
+ refreshCsrfToken(c);
79
82
  return c.json(result, 201);
80
83
  });
81
84
  router.openapi(createRoute({
@@ -99,7 +102,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
99
102
  return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
100
103
  }
101
104
  const metadata = {
102
- ipAddress: clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")),
105
+ ipAddress: getClientIp(c),
103
106
  userAgent: c.req.header("user-agent") ?? undefined,
104
107
  };
105
108
  try {
@@ -110,6 +113,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
110
113
  if (result.refreshToken) {
111
114
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
112
115
  }
116
+ if (getCsrfEnabled())
117
+ refreshCsrfToken(c);
113
118
  }
114
119
  return c.json(result, 200);
115
120
  }
@@ -255,6 +260,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
255
260
  await AuthService.logout(token);
256
261
  deleteCookie(c, COOKIE_TOKEN, { path: "/" });
257
262
  deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
263
+ if (getCsrfEnabled())
264
+ clearCsrfToken(c);
258
265
  return c.json({ message: "Logged out" }, 200);
259
266
  });
260
267
  // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
@@ -272,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
272
279
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
273
280
  },
274
281
  }), async (c) => {
275
- const ip = c.req.header("x-forwarded-for") ?? "unknown";
282
+ const ip = getClientIp(c);
276
283
  if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
277
284
  return c.json({ error: "Too many verification attempts. Try again later." }, 429);
278
285
  }
@@ -341,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
341
348
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
342
349
  },
343
350
  }), async (c) => {
344
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
351
+ const ip = getClientIp(c);
345
352
  const { email } = c.req.valid("json");
346
353
  // Rate-limit by both IP and email to prevent distributed email-bombing
347
354
  const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
@@ -379,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
379
386
  "application/json": {
380
387
  schema: z.object({
381
388
  token: z.string().describe("Single-use reset token received via email."),
382
- password: z.string().min(8).describe("New password. Minimum 8 characters."),
389
+ password: resetPasswordSchema().describe("New password."),
383
390
  }),
384
391
  },
385
392
  },
@@ -393,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
393
400
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
394
401
  },
395
402
  }), async (c) => {
396
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
403
+ const ip = getClientIp(c);
397
404
  if (await trackAttempt(`reset:${ip}`, resetOpts)) {
398
405
  return c.json({ error: "Too many attempts. Try again later." }, 429);
399
406
  }
@@ -444,8 +451,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
444
451
  responses: {
445
452
  200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
446
453
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
454
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
447
455
  },
448
456
  }), async (c) => {
457
+ const ip = getClientIp(c);
458
+ if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
459
+ return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
460
+ }
449
461
  const body = c.req.valid("json");
450
462
  const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
451
463
  if (!rt) {
@@ -1 +1,5 @@
1
- export declare const createMfaRouter: () => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
1
+ import type { AuthRateLimitConfig } from "../app";
2
+ export interface MfaRouterOptions {
3
+ rateLimit?: AuthRateLimitConfig;
4
+ }
5
+ export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
@@ -7,8 +7,11 @@ import * as MfaService from "../services/mfa";
7
7
  import * as AuthService from "../services/auth";
8
8
  import { consumeMfaChallenge, replaceMfaChallengeOtp } from "../lib/mfaChallenge";
9
9
  import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
10
- import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig } from "../lib/appConfig";
10
+ import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getCsrfEnabled } from "../lib/appConfig";
11
+ import { refreshCsrfToken } from "../middleware/csrf";
11
12
  import { getAuthAdapter } from "../lib/authAdapter";
13
+ import { trackAttempt } from "../lib/authRateLimit";
14
+ import { getClientIp } from "../lib/clientIp";
12
15
  const isProd = process.env.NODE_ENV === "production";
13
16
  const cookieOptions = (maxAge) => ({
14
17
  httpOnly: true,
@@ -19,8 +22,11 @@ const cookieOptions = (maxAge) => ({
19
22
  });
20
23
  const tags = ["MFA"];
21
24
  const ErrorResponse = z.object({ error: z.string() }).openapi("MfaErrorResponse");
22
- export const createMfaRouter = () => {
25
+ export const createMfaRouter = ({ rateLimit } = {}) => {
23
26
  const router = createRouter();
27
+ // Resolve MFA rate limits with defaults
28
+ const mfaVerifyOpts = { windowMs: rateLimit?.mfaVerify?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.mfaVerify?.max ?? 10 };
29
+ const mfaResendOpts = { windowMs: rateLimit?.mfaResend?.windowMs ?? 60 * 1000, max: rateLimit?.mfaResend?.max ?? 5 };
24
30
  // All MFA setup/management routes require auth
25
31
  router.use("/auth/mfa/setup", userAuth);
26
32
  router.use("/auth/mfa/verify-setup", userAuth);
@@ -107,7 +113,7 @@ export const createMfaRouter = () => {
107
113
  method: "post",
108
114
  path: "/auth/mfa/verify",
109
115
  summary: "Complete MFA login",
110
- description: "Completes login by verifying a TOTP code, email OTP code, or recovery code after password authentication. Requires the mfaToken returned from the login endpoint. Optionally specify 'method' to target a specific verification method.",
116
+ description: "Completes login by verifying a TOTP code, email OTP code, recovery code, or WebAuthn assertion after password authentication. Requires the mfaToken returned from the login endpoint. Optionally specify 'method' to target a specific verification method.",
111
117
  tags,
112
118
  request: {
113
119
  body: {
@@ -115,8 +121,9 @@ export const createMfaRouter = () => {
115
121
  "application/json": {
116
122
  schema: z.object({
117
123
  mfaToken: z.string().describe("MFA challenge token from the login response."),
118
- code: z.string().describe("6-digit TOTP/email OTP code or 8-character recovery code."),
119
- method: z.enum(["totp", "emailOtp"]).optional().describe("Specify which MFA method to verify. If omitted, methods are tried automatically."),
124
+ code: z.string().optional().describe("6-digit TOTP/email OTP code or 8-character recovery code. Required unless using WebAuthn."),
125
+ method: z.enum(["totp", "emailOtp", "webauthn"]).optional().describe("Specify which MFA method to verify. If omitted, methods are tried automatically."),
126
+ webauthnResponse: z.record(z.string(), z.unknown()).optional().describe("WebAuthn authentication response from navigator.credentials.get(). Pass the entire response object."),
120
127
  }),
121
128
  },
122
129
  },
@@ -125,24 +132,39 @@ export const createMfaRouter = () => {
125
132
  responses: {
126
133
  200: { content: { "application/json": { schema: MfaLoginResponse } }, description: "MFA verified. Session created." },
127
134
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired MFA token, or invalid code." },
135
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many MFA verification attempts. Try again later." },
128
136
  },
129
137
  }), async (c) => {
130
- const { mfaToken, code, method } = c.req.valid("json");
138
+ const ip = getClientIp(c);
139
+ if (await trackAttempt(`mfa-verify:${ip}`, mfaVerifyOpts)) {
140
+ return c.json({ error: "Too many MFA verification attempts. Try again later." }, 429);
141
+ }
142
+ const { mfaToken, code, method, webauthnResponse } = c.req.valid("json");
143
+ if (!code && !webauthnResponse) {
144
+ return c.json({ error: "Either 'code' or 'webauthnResponse' is required" }, 401);
145
+ }
131
146
  const challenge = await consumeMfaChallenge(mfaToken);
132
147
  if (!challenge)
133
148
  return c.json({ error: "Invalid or expired MFA token" }, 401);
134
- const { userId, emailOtpHash } = challenge;
149
+ const { userId, emailOtpHash, webauthnChallenge } = challenge;
135
150
  let valid = false;
136
- if (method === "totp") {
151
+ if (method === "webauthn" || (!method && webauthnResponse)) {
152
+ // WebAuthn verification
153
+ if (webauthnResponse && webauthnChallenge) {
154
+ valid = await MfaService.verifyWebAuthn(userId, webauthnResponse, webauthnChallenge);
155
+ }
156
+ }
157
+ else if (method === "totp") {
137
158
  // Only try TOTP
138
- valid = await MfaService.verifyTotp(userId, code);
159
+ if (code)
160
+ valid = await MfaService.verifyTotp(userId, code);
139
161
  }
140
162
  else if (method === "emailOtp") {
141
163
  // Only try email OTP
142
- if (emailOtpHash)
164
+ if (code && emailOtpHash)
143
165
  valid = MfaService.verifyEmailOtp(emailOtpHash, code);
144
166
  }
145
- else {
167
+ else if (code) {
146
168
  // Auto-detect: use emailOtpHash presence to pick order
147
169
  if (emailOtpHash) {
148
170
  // Email OTP first, then TOTP, then recovery
@@ -155,15 +177,15 @@ export const createMfaRouter = () => {
155
177
  valid = await MfaService.verifyTotp(userId, code);
156
178
  }
157
179
  }
158
- // Always try recovery code as fallback
159
- if (!valid) {
180
+ // Always try recovery code as fallback (code-based only)
181
+ if (!valid && code) {
160
182
  valid = await MfaService.verifyRecoveryCode(userId, code);
161
183
  }
162
184
  if (!valid)
163
185
  return c.json({ error: "Invalid MFA code" }, 401);
164
186
  // Create session — reuse the service helper for refresh token support
165
187
  const result = await AuthService.createSessionForUser(userId, {
166
- ipAddress: (c.req.header("x-forwarded-for")?.split(",")[0]?.trim()) ?? c.req.header("x-real-ip") ?? undefined,
188
+ ipAddress: getClientIp(c),
167
189
  userAgent: c.req.header("user-agent") ?? undefined,
168
190
  });
169
191
  const rtConfig = getRefreshTokenConfig();
@@ -171,6 +193,8 @@ export const createMfaRouter = () => {
171
193
  if (result.refreshToken) {
172
194
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
173
195
  }
196
+ if (getCsrfEnabled())
197
+ refreshCsrfToken(c);
174
198
  return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
175
199
  });
176
200
  // ─── Disable MFA ──────────────────────────────────────────────────────────
@@ -364,6 +388,10 @@ export const createMfaRouter = () => {
364
388
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Maximum resends reached." },
365
389
  },
366
390
  }), async (c) => {
391
+ const ip = getClientIp(c);
392
+ if (await trackAttempt(`mfa-resend:${ip}`, mfaResendOpts)) {
393
+ return c.json({ error: "Too many resend attempts. Try again later." }, 429);
394
+ }
367
395
  const { mfaToken } = c.req.valid("json");
368
396
  const emailOtpConfig = getMfaEmailOtpConfig();
369
397
  if (!emailOtpConfig)
@@ -405,5 +433,184 @@ export const createMfaRouter = () => {
405
433
  const methods = await MfaService.getMfaMethods(userId);
406
434
  return c.json({ methods }, 200);
407
435
  });
436
+ // ─── WebAuthn / Security Keys ─────────────────────────────────────────────
437
+ if (getMfaWebAuthnConfig()) {
438
+ // Eager dependency check — fail fast at server start
439
+ MfaService.assertWebAuthnDependency().catch((err) => { throw err; });
440
+ router.use("/auth/mfa/webauthn/*", userAuth);
441
+ // Register options
442
+ router.openapi(withSecurity(createRoute({
443
+ method: "post",
444
+ path: "/auth/mfa/webauthn/register-options",
445
+ summary: "Generate WebAuthn registration options",
446
+ description: "Generates registration options for the client to pass to navigator.credentials.create(). Returns a registrationToken to confirm registration.",
447
+ tags,
448
+ responses: {
449
+ 200: {
450
+ content: {
451
+ "application/json": {
452
+ schema: z.object({
453
+ options: z.record(z.string(), z.unknown()).describe("PublicKeyCredentialCreationOptions — pass directly to navigator.credentials.create()."),
454
+ registrationToken: z.string().describe("Token to pass back when completing registration."),
455
+ }),
456
+ },
457
+ },
458
+ description: "Registration options generated.",
459
+ },
460
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
461
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "WebAuthn not configured or adapter does not support it." },
462
+ },
463
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
464
+ const userId = c.get("authUserId");
465
+ const result = await MfaService.initiateWebAuthnRegistration(userId);
466
+ return c.json(result, 200);
467
+ });
468
+ // Complete registration
469
+ router.openapi(withSecurity(createRoute({
470
+ method: "post",
471
+ path: "/auth/mfa/webauthn/register",
472
+ summary: "Complete WebAuthn registration",
473
+ description: "Verifies the attestation response from navigator.credentials.create() and stores the credential. Returns recovery codes.",
474
+ tags,
475
+ request: {
476
+ body: {
477
+ content: {
478
+ "application/json": {
479
+ schema: z.object({
480
+ registrationToken: z.string().describe("Token from POST /auth/mfa/webauthn/register-options."),
481
+ attestationResponse: z.record(z.string(), z.unknown()).describe("Full response from navigator.credentials.create()."),
482
+ name: z.string().optional().describe("User-friendly name for the key (e.g. 'YubiKey 5')."),
483
+ }),
484
+ },
485
+ },
486
+ },
487
+ },
488
+ responses: {
489
+ 200: {
490
+ content: {
491
+ "application/json": {
492
+ schema: z.object({
493
+ message: z.string(),
494
+ credentialId: z.string(),
495
+ recoveryCodes: z.array(z.string()).nullable().describe("Recovery codes (always returned when WebAuthn is enabled)."),
496
+ }),
497
+ },
498
+ },
499
+ description: "Security key registered.",
500
+ },
501
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid registration token or verification failed." },
502
+ 409: { content: { "application/json": { schema: ErrorResponse } }, description: "Security key already registered to another account." },
503
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "WebAuthn not configured or adapter does not support it." },
504
+ },
505
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
506
+ const userId = c.get("authUserId");
507
+ const { registrationToken, attestationResponse, name } = c.req.valid("json");
508
+ const result = await MfaService.completeWebAuthnRegistration(userId, registrationToken, attestationResponse, name);
509
+ return c.json({ message: "Security key registered", ...result }, 200);
510
+ });
511
+ // List credentials
512
+ router.openapi(withSecurity(createRoute({
513
+ method: "get",
514
+ path: "/auth/mfa/webauthn/credentials",
515
+ summary: "List WebAuthn credentials",
516
+ description: "Returns the security keys registered for the authenticated user. Does not include private key data.",
517
+ tags,
518
+ responses: {
519
+ 200: {
520
+ content: {
521
+ "application/json": {
522
+ schema: z.object({
523
+ credentials: z.array(z.object({
524
+ credentialId: z.string(),
525
+ name: z.string().optional(),
526
+ createdAt: z.number(),
527
+ transports: z.array(z.string()).optional(),
528
+ })),
529
+ }),
530
+ },
531
+ },
532
+ description: "List of registered security keys.",
533
+ },
534
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
535
+ },
536
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
537
+ const userId = c.get("authUserId");
538
+ const adapter = getAuthAdapter();
539
+ const creds = adapter.getWebAuthnCredentials ? await adapter.getWebAuthnCredentials(userId) : [];
540
+ return c.json({
541
+ credentials: creds.map((cr) => ({
542
+ credentialId: cr.credentialId,
543
+ name: cr.name,
544
+ createdAt: cr.createdAt,
545
+ transports: cr.transports,
546
+ })),
547
+ }, 200);
548
+ });
549
+ // Remove a single credential
550
+ router.openapi(withSecurity(createRoute({
551
+ method: "delete",
552
+ path: "/auth/mfa/webauthn/credentials/{credentialId}",
553
+ summary: "Remove a WebAuthn credential",
554
+ description: "Removes a single security key. Identity verification is only required when removing the last MFA credential.",
555
+ tags,
556
+ request: {
557
+ params: z.object({ credentialId: z.string() }),
558
+ body: {
559
+ content: {
560
+ "application/json": {
561
+ schema: z.object({
562
+ code: z.string().optional().describe("TOTP code (required when removing the last MFA credential, if TOTP is enabled)."),
563
+ password: z.string().optional().describe("Password (required when removing the last MFA credential, if no TOTP)."),
564
+ }),
565
+ },
566
+ },
567
+ },
568
+ },
569
+ responses: {
570
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Credential removed." },
571
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Missing required verification." },
572
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code/password or no valid session." },
573
+ 404: { content: { "application/json": { schema: ErrorResponse } }, description: "Credential not found." },
574
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Adapter does not support WebAuthn." },
575
+ },
576
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
577
+ const userId = c.get("authUserId");
578
+ const { credentialId } = c.req.valid("param");
579
+ const { code, password } = c.req.valid("json");
580
+ await MfaService.removeWebAuthnCredential(userId, credentialId, { code, password });
581
+ return c.json({ message: "Credential removed" }, 200);
582
+ });
583
+ // Disable WebAuthn entirely
584
+ router.openapi(withSecurity(createRoute({
585
+ method: "delete",
586
+ path: "/auth/mfa/webauthn",
587
+ summary: "Disable WebAuthn MFA",
588
+ description: "Removes all WebAuthn credentials and disables WebAuthn as an MFA method. Requires identity verification.",
589
+ tags,
590
+ request: {
591
+ body: {
592
+ content: {
593
+ "application/json": {
594
+ schema: z.object({
595
+ code: z.string().optional().describe("TOTP code (if TOTP is enabled)."),
596
+ password: z.string().optional().describe("Password (if TOTP is not enabled)."),
597
+ }),
598
+ },
599
+ },
600
+ },
601
+ },
602
+ responses: {
603
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "WebAuthn disabled." },
604
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Missing required verification." },
605
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code/password or no valid session." },
606
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Adapter does not support WebAuthn." },
607
+ },
608
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
609
+ const userId = c.get("authUserId");
610
+ const { code, password } = c.req.valid("json");
611
+ await MfaService.disableWebAuthn(userId, { code, password });
612
+ return c.json({ message: "WebAuthn disabled" }, 200);
613
+ });
614
+ }
408
615
  return router;
409
616
  };