@lastshotlabs/bunshot 0.0.21 → 0.0.27

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 (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -1,4 +1,5 @@
1
1
  import { createRoute, withSecurity } from "../lib/createRoute";
2
+ import { HttpError } from "../lib/HttpError";
2
3
  import { z } from "zod";
3
4
  import { setCookie, getCookie, deleteCookie } from "hono/cookie";
4
5
  import * as AuthService from "../services/auth";
@@ -6,14 +7,18 @@ import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../sch
6
7
  import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
7
8
  import { userAuth } from "../middleware/userAuth";
8
9
  import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
10
+ import { isStuffingBlocked, trackFailedLogin } from "../lib/credentialStuffing";
9
11
  import { getAuthAdapter } from "../lib/authAdapter";
10
12
  import { createRouter } from "../lib/context";
11
- import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
13
+ import { consumeVerificationToken, createVerificationToken } from "../lib/emailVerification";
12
14
  import { createResetToken, consumeResetToken } from "../lib/resetPassword";
13
- import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
15
+ import { createDeletionCancelToken, consumeDeletionCancelToken } from "../lib/deletionCancelToken";
16
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName, getBreachedPasswordConfig } from "../lib/appConfig";
17
+ import { checkBreachedPassword } from "../lib/breachedPassword";
14
18
  import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
15
- import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
19
+ import { getUserSessions, deleteSession, deleteUserSessions, setMfaVerifiedAt } from "../lib/session";
16
20
  import { getClientIp } from "../lib/clientIp";
21
+ import { emitSecurityEvent } from "../lib/securityEvents";
17
22
  const isProd = process.env.NODE_ENV === "production";
18
23
  const TokenResponse = z.object({
19
24
  token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
@@ -24,7 +29,8 @@ const TokenResponse = z.object({
24
29
  refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
25
30
  mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
26
31
  mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
27
- mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
32
+ mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp', 'webauthn')."),
33
+ webauthnOptions: z.record(z.string(), z.unknown()).optional().describe("WebAuthn assertion options (present when mfaMethods includes 'webauthn'). Pass directly to navigator.credentials.get()."),
28
34
  }).openapi("TokenResponse");
29
35
  const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
30
36
  const tags = ["Auth"];
@@ -35,7 +41,7 @@ const cookieOptions = (maxAge) => ({
35
41
  path: "/",
36
42
  maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
37
43
  });
38
- export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
44
+ export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens, stepUp }) => {
39
45
  const router = createRouter();
40
46
  const RegisterSchema = makeRegisterSchema(primaryField);
41
47
  const LoginSchema = makeLoginSchema(primaryField);
@@ -64,10 +70,18 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
64
70
  }), async (c) => {
65
71
  const ip = getClientIp(c);
66
72
  if (await trackAttempt(`register:${ip}`, registerOpts)) {
73
+ emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
67
74
  return c.json({ error: "Too many registration attempts. Try again later." }, 429);
68
75
  }
69
76
  const body = c.req.valid("json");
70
77
  const identifier = body[primaryField];
78
+ const breachConfig = getBreachedPasswordConfig();
79
+ if (breachConfig) {
80
+ const { breached } = await checkBreachedPassword(body.password, breachConfig);
81
+ if (breached && breachConfig.block !== false) {
82
+ throw new HttpError(400, "This password has appeared in a data breach. Please choose a different password.", "BREACHED_PASSWORD");
83
+ }
84
+ }
71
85
  const metadata = {
72
86
  ipAddress: ip !== "unknown" ? ip : undefined,
73
87
  userAgent: c.req.header("user-agent") ?? undefined,
@@ -79,7 +93,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
79
93
  }
80
94
  if (getCsrfEnabled())
81
95
  refreshCsrfToken(c);
82
- return c.json(result, 201);
96
+ const { refreshToken: _rt, ...safeResult } = result;
97
+ return c.json(result.refreshToken ? safeResult : result, 201);
83
98
  });
84
99
  router.openapi(createRoute({
85
100
  method: "post",
@@ -98,11 +113,23 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
98
113
  const body = c.req.valid("json");
99
114
  const identifier = body[primaryField];
100
115
  const limitKey = `login:${identifier}`;
116
+ const clientIp = getClientIp(c) ?? "unknown";
117
+ if (isStuffingBlocked(clientIp, identifier)) {
118
+ emitSecurityEvent({
119
+ eventType: "security.credential_stuffing.detected",
120
+ severity: "critical",
121
+ timestamp: new Date().toISOString(),
122
+ ip: clientIp,
123
+ meta: { identifier },
124
+ });
125
+ throw new HttpError(429, "Too many login attempts from this source", "CREDENTIAL_STUFFING_BLOCKED");
126
+ }
101
127
  if (await isLimited(limitKey, loginOpts)) {
128
+ emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
102
129
  return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
103
130
  }
104
131
  const metadata = {
105
- ipAddress: getClientIp(c),
132
+ ipAddress: clientIp !== "unknown" ? clientIp : undefined,
106
133
  userAgent: c.req.header("user-agent") ?? undefined,
107
134
  };
108
135
  try {
@@ -116,9 +143,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
116
143
  if (getCsrfEnabled())
117
144
  refreshCsrfToken(c);
118
145
  }
119
- return c.json(result, 200);
146
+ const { refreshToken: _rt, ...safeResult } = result;
147
+ return c.json(result.refreshToken ? safeResult : result, 200);
120
148
  }
121
149
  catch (err) {
150
+ if (err instanceof HttpError && err.status === 401) {
151
+ trackFailedLogin(clientIp, identifier);
152
+ }
122
153
  await trackAttempt(limitKey, loginOpts); // failure — count it
123
154
  throw err;
124
155
  }
@@ -212,6 +243,33 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
212
243
  if (accountDeletion?.onBeforeDelete) {
213
244
  await accountDeletion.onBeforeDelete(authUserId);
214
245
  }
246
+ // Queued deletion via BullMQ
247
+ if (accountDeletion?.queued) {
248
+ const { createQueue } = await import("../lib/queue");
249
+ const appName = getAppName();
250
+ const queue = createQueue(`${appName}:account-deletions`);
251
+ const delayMs = (accountDeletion.gracePeriod ?? 0) * 1000;
252
+ const job = await queue.add("delete-account", { userId: authUserId }, {
253
+ delay: delayMs,
254
+ attempts: 3,
255
+ backoff: { type: "exponential", delay: 1000 },
256
+ removeOnComplete: true,
257
+ removeOnFail: 100,
258
+ });
259
+ await queue.close();
260
+ // Revoke sessions immediately so the user is logged out
261
+ await deleteUserSessions(authUserId);
262
+ if (accountDeletion.gracePeriod && accountDeletion.onDeletionScheduled) {
263
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
264
+ const email = user?.email ?? "";
265
+ if (email) {
266
+ const cancelToken = await createDeletionCancelToken(authUserId, job.id, accountDeletion.gracePeriod);
267
+ await accountDeletion.onDeletionScheduled(authUserId, email, cancelToken);
268
+ }
269
+ }
270
+ deleteCookie(c, COOKIE_TOKEN, { path: "/" });
271
+ return c.json({ message: "Account deletion scheduled" }, 202);
272
+ }
215
273
  // Synchronous deletion (default)
216
274
  await deleteUserSessions(authUserId);
217
275
  await adapter.deleteUser(authUserId);
@@ -226,13 +284,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
226
284
  method: "post",
227
285
  path: "/auth/set-password",
228
286
  summary: "Set or update password",
229
- description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. Requires a valid session.",
287
+ description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. If the account already has a password set, `currentPassword` is required. Requires a valid session.",
230
288
  tags,
231
- request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8).describe("New password. Minimum 8 characters.") }) } }, description: "New password." } },
289
+ request: {
290
+ body: {
291
+ content: {
292
+ "application/json": {
293
+ schema: z.object({
294
+ password: z.string().min(8).describe("New password."),
295
+ currentPassword: z.string().optional().describe("Current password. Required if the account already has a password set."),
296
+ }),
297
+ },
298
+ },
299
+ description: "New password.",
300
+ },
301
+ },
232
302
  responses: {
233
303
  200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
234
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
235
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
304
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or current password is required when one is already set." },
305
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session, or current password is incorrect." },
236
306
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
237
307
  },
238
308
  }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
@@ -240,10 +310,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
240
310
  if (!adapter.setPassword) {
241
311
  return c.json({ error: "Auth adapter does not support setPassword" }, 501);
242
312
  }
243
- const { password } = c.req.valid("json");
313
+ const { password, currentPassword } = c.req.valid("json");
244
314
  const authUserId = c.get("authUserId");
315
+ // If the user already has a password, require currentPassword to change it
316
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
317
+ if (user) {
318
+ const findFn = adapter.findByIdentifier ?? (user.email ? adapter.findByEmail.bind(adapter) : null);
319
+ if (findFn && user.email) {
320
+ const found = await findFn(user.email);
321
+ if (found?.passwordHash) {
322
+ if (!currentPassword) {
323
+ return c.json({ error: "Current password is required to change an existing password." }, 400);
324
+ }
325
+ if (!(await Bun.password.verify(currentPassword, found.passwordHash))) {
326
+ return c.json({ error: "Current password is incorrect." }, 401);
327
+ }
328
+ }
329
+ }
330
+ }
245
331
  const passwordHash = await Bun.password.hash(password);
246
332
  await adapter.setPassword(authUserId, passwordHash);
333
+ emitSecurityEvent({ eventType: "auth.password.change", severity: "info", timestamp: new Date().toISOString() });
247
334
  return c.json({ message: "Password updated" }, 200);
248
335
  });
249
336
  router.openapi(createRoute({
@@ -285,24 +372,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
285
372
  }
286
373
  const { token } = c.req.valid("json");
287
374
  const adapter = getAuthAdapter();
288
- const entry = await getVerificationToken(token);
375
+ const entry = await consumeVerificationToken(token);
289
376
  if (!entry)
290
377
  return c.json({ error: "Invalid or expired verification token" }, 400);
291
378
  if (adapter.setEmailVerified)
292
379
  await adapter.setEmailVerified(entry.userId, true);
293
- await deleteVerificationToken(token);
380
+ if (getCsrfEnabled())
381
+ refreshCsrfToken(c);
294
382
  return c.json({ message: "Email verified" }, 200);
295
383
  });
296
384
  router.openapi(createRoute({
297
385
  method: "post",
298
386
  path: "/auth/resend-verification",
299
387
  summary: "Resend verification email",
300
- description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
388
+ description: "Authenticates with credentials and sends a new verification email. Always returns 200 for valid credentials regardless of verification status, to prevent user enumeration. Rate-limited per identifier. Does not require a session.",
301
389
  tags,
302
390
  request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
303
391
  responses: {
304
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
305
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
392
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent, or account is already verified (indistinguishable by design)." },
393
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "No email address on file for this account." },
306
394
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
307
395
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
308
396
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
@@ -323,8 +411,10 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
323
411
  return c.json({ error: "Invalid credentials" }, 401);
324
412
  }
325
413
  const alreadyVerified = await adapter.getEmailVerified(user.id);
414
+ // Return 200 (not 400) to avoid revealing whether the account is verified —
415
+ // distinguishing verified vs unverified would let attackers confirm valid credentials.
326
416
  if (alreadyVerified)
327
- return c.json({ error: "Email already verified" }, 400);
417
+ return c.json({ message: "Verification email sent if not already verified" }, 200);
328
418
  const fullUser = await adapter.getUser(user.id);
329
419
  if (!fullUser?.email)
330
420
  return c.json({ error: "No email address on file" }, 400);
@@ -405,6 +495,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
405
495
  return c.json({ error: "Too many attempts. Try again later." }, 429);
406
496
  }
407
497
  const { token, password } = c.req.valid("json");
498
+ const breachConfig = getBreachedPasswordConfig();
499
+ if (breachConfig) {
500
+ const { breached } = await checkBreachedPassword(password, breachConfig);
501
+ if (breached && breachConfig.block !== false) {
502
+ throw new HttpError(400, "This password has appeared in a data breach. Please choose a different password.", "BREACHED_PASSWORD");
503
+ }
504
+ }
408
505
  // consumeResetToken atomically gets and deletes — prevents concurrent replay
409
506
  const entry = await consumeResetToken(token);
410
507
  if (!entry)
@@ -418,6 +515,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
418
515
  // Revoke all sessions so stolen JWTs can't stay valid after a reset
419
516
  const sessions = await getUserSessions(entry.userId);
420
517
  await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
518
+ emitSecurityEvent({ eventType: "auth.password.reset", severity: "info", timestamp: new Date().toISOString() });
421
519
  return c.json({ message: "Password reset successfully" }, 200);
422
520
  });
423
521
  }
@@ -427,7 +525,6 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
427
525
  if (refreshTokens) {
428
526
  const RefreshResponse = z.object({
429
527
  token: z.string().describe("New short-lived JWT access token."),
430
- refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
431
528
  userId: z.string().describe("Unique user ID."),
432
529
  }).openapi("RefreshResponse");
433
530
  router.openapi(createRoute({
@@ -466,7 +563,127 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
466
563
  const result = await AuthService.refresh(rt);
467
564
  setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
468
565
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
469
- return c.json(result, 200);
566
+ const { refreshToken: _rt, ...safeResult } = result;
567
+ return c.json(safeResult, 200);
568
+ });
569
+ }
570
+ // ---------------------------------------------------------------------------
571
+ // Account deletion cancellation — only mounted when queued: true + gracePeriod > 0
572
+ // ---------------------------------------------------------------------------
573
+ if (accountDeletion?.queued && accountDeletion.gracePeriod) {
574
+ router.openapi(createRoute({
575
+ method: "post",
576
+ path: "/auth/cancel-deletion",
577
+ summary: "Cancel scheduled account deletion",
578
+ description: "Cancels a pending queued account deletion using the cancel token sent via the onDeletionScheduled callback. Must be called before the grace period expires.",
579
+ tags,
580
+ request: {
581
+ body: {
582
+ content: {
583
+ "application/json": {
584
+ schema: z.object({
585
+ token: z.string().describe("Cancel token received in the deletion scheduled notification."),
586
+ }),
587
+ },
588
+ },
589
+ description: "Cancel token.",
590
+ },
591
+ },
592
+ responses: {
593
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion cancelled." },
594
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired cancel token." },
595
+ },
596
+ }), async (c) => {
597
+ const { token } = c.req.valid("json");
598
+ const entry = await consumeDeletionCancelToken(token);
599
+ if (!entry)
600
+ return c.json({ error: "Invalid or expired cancel token" }, 400);
601
+ // Remove the pending BullMQ job
602
+ try {
603
+ const { createQueue } = await import("../lib/queue");
604
+ const appName = getAppName();
605
+ const queue = createQueue(`${appName}:account-deletions`);
606
+ const job = await queue.getJob(entry.jobId);
607
+ if (job)
608
+ await job.remove();
609
+ await queue.close();
610
+ }
611
+ catch {
612
+ // Job may have already executed — that's an error case but we still consumed the token
613
+ }
614
+ return c.json({ message: "Account deletion cancelled" }, 200);
615
+ });
616
+ }
617
+ // ---------------------------------------------------------------------------
618
+ // Step-up MFA re-authentication — only mounted when stepUp is configured
619
+ // ---------------------------------------------------------------------------
620
+ if (stepUp) {
621
+ const stepUpOpts = { windowMs: rateLimit?.mfaVerify?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.mfaVerify?.max ?? 10 };
622
+ const stepUpSchema = z.object({
623
+ mfaCode: z.string().optional().describe("6-digit TOTP code or recovery code."),
624
+ password: z.string().optional().describe("Account password."),
625
+ }).refine(data => data.mfaCode || data.password, {
626
+ message: "Either mfaCode or password is required",
627
+ });
628
+ router.use("/auth/step-up", userAuth);
629
+ router.openapi(withSecurity(createRoute({
630
+ method: "post",
631
+ path: "/auth/step-up",
632
+ summary: "Step-up MFA re-authentication",
633
+ description: "Re-authenticates the current session via TOTP code or password to satisfy step-up requirements for sensitive operations. On success, sets mfaVerifiedAt in the session.",
634
+ tags,
635
+ request: {
636
+ body: {
637
+ content: {
638
+ "application/json": {
639
+ schema: stepUpSchema,
640
+ },
641
+ },
642
+ description: "TOTP code or password for re-authentication.",
643
+ },
644
+ },
645
+ responses: {
646
+ 200: { content: { "application/json": { schema: z.object({ ok: z.literal(true) }) } }, description: "Step-up authentication successful." },
647
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Neither mfaCode nor password provided." },
648
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code or password, or no valid session." },
649
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many step-up attempts. Try again later." },
650
+ },
651
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
652
+ const ip = getClientIp(c);
653
+ if (await trackAttempt(`step-up:${ip}`, stepUpOpts)) {
654
+ return c.json({ error: "Too many step-up attempts. Try again later." }, 429);
655
+ }
656
+ const userId = c.get("authUserId");
657
+ const sessionId = c.get("sessionId");
658
+ const { mfaCode, password } = c.req.valid("json");
659
+ let valid = false;
660
+ if (mfaCode) {
661
+ const { verifyTotp, verifyRecoveryCode } = await import("../services/mfa");
662
+ valid = await verifyTotp(userId, mfaCode);
663
+ if (!valid)
664
+ valid = await verifyRecoveryCode(userId, mfaCode);
665
+ }
666
+ else if (password) {
667
+ const adapter = getAuthAdapter();
668
+ const findFn = adapter.findByIdentifier ?? (adapter.findByEmail ? adapter.findByEmail.bind(adapter) : null);
669
+ if (findFn) {
670
+ const user = adapter.getUser ? await adapter.getUser(userId) : null;
671
+ const identifier = user?.email ?? "";
672
+ if (identifier) {
673
+ const found = await findFn(identifier);
674
+ if (found?.passwordHash) {
675
+ valid = await Bun.password.verify(password, found.passwordHash);
676
+ }
677
+ }
678
+ }
679
+ }
680
+ if (!valid) {
681
+ emitSecurityEvent({ eventType: "auth.step_up.failure", severity: "warn", timestamp: new Date().toISOString(), userId });
682
+ return c.json({ error: "Invalid credentials" }, 401);
683
+ }
684
+ await setMfaVerifiedAt(sessionId);
685
+ emitSecurityEvent({ eventType: "auth.step_up.success", severity: "info", timestamp: new Date().toISOString(), userId });
686
+ return c.json({ ok: true }, 200);
470
687
  });
471
688
  }
472
689
  // ---------------------------------------------------------------------------
@@ -0,0 +1,21 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "../lib/context";
3
+ export interface GroupsManagementConfig {
4
+ /**
5
+ * Role required to access all management routes.
6
+ * Applied via requireRole.global(adminRole). Default: "admin".
7
+ * Ignored if `middleware` is provided.
8
+ */
9
+ adminRole?: string;
10
+ /**
11
+ * Fully replaces the default auth middleware stack [userAuth, requireRole.global(adminRole)].
12
+ * Use only when a single role check is insufficient.
13
+ * When provided, `adminRole` is ignored.
14
+ */
15
+ middleware?: MiddlewareHandler<AppEnv>[];
16
+ }
17
+ export interface GroupsConfig {
18
+ /** Mount group management routes. Pass `true` to use all defaults. */
19
+ managementRoutes?: GroupsManagementConfig | true;
20
+ }
21
+ export declare const createGroupsRouter: (config: GroupsConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;