@lastshotlabs/bunshot 0.0.25 → 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 (108) hide show
  1. package/dist/adapters/localStorage.js +20 -5
  2. package/dist/adapters/memoryAuth.d.ts +6 -0
  3. package/dist/adapters/memoryAuth.js +117 -2
  4. package/dist/adapters/mongoAuth.js +97 -1
  5. package/dist/adapters/sqliteAuth.d.ts +23 -0
  6. package/dist/adapters/sqliteAuth.js +153 -2
  7. package/dist/app.d.ts +105 -2
  8. package/dist/app.js +112 -9
  9. package/dist/index.d.ts +23 -4
  10. package/dist/index.js +13 -2
  11. package/dist/lib/HttpError.d.ts +2 -1
  12. package/dist/lib/HttpError.js +3 -1
  13. package/dist/lib/appConfig.d.ts +113 -0
  14. package/dist/lib/appConfig.js +38 -0
  15. package/dist/lib/auditLog.d.ts +6 -0
  16. package/dist/lib/auditLog.js +17 -0
  17. package/dist/lib/authAdapter.d.ts +71 -1
  18. package/dist/lib/authRateLimit.js +36 -0
  19. package/dist/lib/breachedPassword.d.ts +13 -0
  20. package/dist/lib/breachedPassword.js +48 -0
  21. package/dist/lib/captcha.d.ts +25 -0
  22. package/dist/lib/captcha.js +37 -0
  23. package/dist/lib/context.d.ts +5 -0
  24. package/dist/lib/credentialStuffing.d.ts +31 -0
  25. package/dist/lib/credentialStuffing.js +77 -0
  26. package/dist/lib/emailVerification.d.ts +6 -0
  27. package/dist/lib/emailVerification.js +46 -3
  28. package/dist/lib/jwks.d.ts +25 -0
  29. package/dist/lib/jwks.js +51 -0
  30. package/dist/lib/jwt.d.ts +15 -2
  31. package/dist/lib/jwt.js +92 -5
  32. package/dist/lib/logger.d.ts +2 -0
  33. package/dist/lib/logger.js +6 -0
  34. package/dist/lib/m2m.d.ts +29 -0
  35. package/dist/lib/m2m.js +48 -0
  36. package/dist/lib/mfaChallenge.d.ts +14 -1
  37. package/dist/lib/mfaChallenge.js +111 -6
  38. package/dist/lib/mongo.js +1 -1
  39. package/dist/lib/oauthCode.js +23 -18
  40. package/dist/lib/resetPassword.js +3 -1
  41. package/dist/lib/saml.d.ts +25 -0
  42. package/dist/lib/saml.js +64 -0
  43. package/dist/lib/scim.d.ts +44 -0
  44. package/dist/lib/scim.js +54 -0
  45. package/dist/lib/securityEvents.d.ts +28 -0
  46. package/dist/lib/securityEvents.js +26 -0
  47. package/dist/lib/session.d.ts +10 -0
  48. package/dist/lib/session.js +67 -5
  49. package/dist/lib/signing.js +5 -2
  50. package/dist/lib/suspension.d.ts +13 -0
  51. package/dist/lib/suspension.js +23 -0
  52. package/dist/lib/upload.d.ts +4 -0
  53. package/dist/lib/upload.js +26 -1
  54. package/dist/lib/uploadRegistry.d.ts +18 -0
  55. package/dist/lib/uploadRegistry.js +83 -0
  56. package/dist/lib/ws.js +7 -0
  57. package/dist/middleware/bearerAuth.js +1 -1
  58. package/dist/middleware/captcha.d.ts +10 -0
  59. package/dist/middleware/captcha.js +36 -0
  60. package/dist/middleware/csrf.js +8 -4
  61. package/dist/middleware/errorHandler.js +4 -1
  62. package/dist/middleware/identify.js +40 -13
  63. package/dist/middleware/requestSigning.js +6 -5
  64. package/dist/middleware/requireMfaSetup.js +2 -1
  65. package/dist/middleware/requireScope.d.ts +10 -0
  66. package/dist/middleware/requireScope.js +25 -0
  67. package/dist/middleware/requireStepUp.d.ts +18 -0
  68. package/dist/middleware/requireStepUp.js +29 -0
  69. package/dist/middleware/scimAuth.d.ts +8 -0
  70. package/dist/middleware/scimAuth.js +29 -0
  71. package/dist/middleware/webhookAuth.d.ts +1 -1
  72. package/dist/middleware/webhookAuth.js +6 -5
  73. package/dist/models/AuthUser.d.ts +7 -0
  74. package/dist/models/AuthUser.js +7 -0
  75. package/dist/models/M2MClient.d.ts +18 -0
  76. package/dist/models/M2MClient.js +18 -0
  77. package/dist/routes/auth.d.ts +3 -2
  78. package/dist/routes/auth.js +155 -16
  79. package/dist/routes/jobs.js +21 -3
  80. package/dist/routes/m2m.d.ts +2 -0
  81. package/dist/routes/m2m.js +72 -0
  82. package/dist/routes/metrics.d.ts +1 -0
  83. package/dist/routes/metrics.js +3 -0
  84. package/dist/routes/mfa.js +9 -1
  85. package/dist/routes/oauth.js +6 -0
  86. package/dist/routes/oidc.d.ts +2 -0
  87. package/dist/routes/oidc.js +29 -0
  88. package/dist/routes/passkey.d.ts +1 -0
  89. package/dist/routes/passkey.js +157 -0
  90. package/dist/routes/saml.d.ts +2 -0
  91. package/dist/routes/saml.js +86 -0
  92. package/dist/routes/scim.d.ts +2 -0
  93. package/dist/routes/scim.js +255 -0
  94. package/dist/routes/uploads.d.ts +13 -1
  95. package/dist/routes/uploads.js +98 -6
  96. package/dist/services/auth.d.ts +2 -0
  97. package/dist/services/auth.js +101 -22
  98. package/dist/services/mfa.js +2 -2
  99. package/dist/ws/index.js +2 -1
  100. package/docs/sections/auth-flow/full.md +790 -779
  101. package/docs/sections/auth-security-examples/full.md +23 -0
  102. package/docs/sections/metrics/full.md +6 -2
  103. package/docs/sections/passkey-login/full.md +90 -0
  104. package/docs/sections/passkey-login/overview.md +1 -0
  105. package/docs/sections/uploads/full.md +11 -2
  106. package/docs/sections/webhook-auth/full.md +1 -1
  107. package/docs/sections/websocket/full.md +12 -0
  108. package/package.json +3 -2
@@ -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,15 +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
15
  import { createDeletionCancelToken, consumeDeletionCancelToken } from "../lib/deletionCancelToken";
14
- import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName } from "../lib/appConfig";
16
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName, getBreachedPasswordConfig } from "../lib/appConfig";
17
+ import { checkBreachedPassword } from "../lib/breachedPassword";
15
18
  import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
16
- import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
19
+ import { getUserSessions, deleteSession, deleteUserSessions, setMfaVerifiedAt } from "../lib/session";
17
20
  import { getClientIp } from "../lib/clientIp";
21
+ import { emitSecurityEvent } from "../lib/securityEvents";
18
22
  const isProd = process.env.NODE_ENV === "production";
19
23
  const TokenResponse = z.object({
20
24
  token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
@@ -37,7 +41,7 @@ const cookieOptions = (maxAge) => ({
37
41
  path: "/",
38
42
  maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
39
43
  });
40
- export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
44
+ export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens, stepUp }) => {
41
45
  const router = createRouter();
42
46
  const RegisterSchema = makeRegisterSchema(primaryField);
43
47
  const LoginSchema = makeLoginSchema(primaryField);
@@ -66,10 +70,18 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
66
70
  }), async (c) => {
67
71
  const ip = getClientIp(c);
68
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 } });
69
74
  return c.json({ error: "Too many registration attempts. Try again later." }, 429);
70
75
  }
71
76
  const body = c.req.valid("json");
72
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
+ }
73
85
  const metadata = {
74
86
  ipAddress: ip !== "unknown" ? ip : undefined,
75
87
  userAgent: c.req.header("user-agent") ?? undefined,
@@ -81,7 +93,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
81
93
  }
82
94
  if (getCsrfEnabled())
83
95
  refreshCsrfToken(c);
84
- return c.json(result, 201);
96
+ const { refreshToken: _rt, ...safeResult } = result;
97
+ return c.json(result.refreshToken ? safeResult : result, 201);
85
98
  });
86
99
  router.openapi(createRoute({
87
100
  method: "post",
@@ -100,11 +113,23 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
100
113
  const body = c.req.valid("json");
101
114
  const identifier = body[primaryField];
102
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
+ }
103
127
  if (await isLimited(limitKey, loginOpts)) {
128
+ emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
104
129
  return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
105
130
  }
106
131
  const metadata = {
107
- ipAddress: getClientIp(c),
132
+ ipAddress: clientIp !== "unknown" ? clientIp : undefined,
108
133
  userAgent: c.req.header("user-agent") ?? undefined,
109
134
  };
110
135
  try {
@@ -118,9 +143,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
118
143
  if (getCsrfEnabled())
119
144
  refreshCsrfToken(c);
120
145
  }
121
- return c.json(result, 200);
146
+ const { refreshToken: _rt, ...safeResult } = result;
147
+ return c.json(result.refreshToken ? safeResult : result, 200);
122
148
  }
123
149
  catch (err) {
150
+ if (err instanceof HttpError && err.status === 401) {
151
+ trackFailedLogin(clientIp, identifier);
152
+ }
124
153
  await trackAttempt(limitKey, loginOpts); // failure — count it
125
154
  throw err;
126
155
  }
@@ -255,13 +284,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
255
284
  method: "post",
256
285
  path: "/auth/set-password",
257
286
  summary: "Set or update password",
258
- 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.",
259
288
  tags,
260
- 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
+ },
261
302
  responses: {
262
303
  200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
263
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
264
- 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." },
265
306
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
266
307
  },
267
308
  }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
@@ -269,10 +310,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
269
310
  if (!adapter.setPassword) {
270
311
  return c.json({ error: "Auth adapter does not support setPassword" }, 501);
271
312
  }
272
- const { password } = c.req.valid("json");
313
+ const { password, currentPassword } = c.req.valid("json");
273
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
+ }
274
331
  const passwordHash = await Bun.password.hash(password);
275
332
  await adapter.setPassword(authUserId, passwordHash);
333
+ emitSecurityEvent({ eventType: "auth.password.change", severity: "info", timestamp: new Date().toISOString() });
276
334
  return c.json({ message: "Password updated" }, 200);
277
335
  });
278
336
  router.openapi(createRoute({
@@ -314,12 +372,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
314
372
  }
315
373
  const { token } = c.req.valid("json");
316
374
  const adapter = getAuthAdapter();
317
- const entry = await getVerificationToken(token);
375
+ const entry = await consumeVerificationToken(token);
318
376
  if (!entry)
319
377
  return c.json({ error: "Invalid or expired verification token" }, 400);
320
378
  if (adapter.setEmailVerified)
321
379
  await adapter.setEmailVerified(entry.userId, true);
322
- await deleteVerificationToken(token);
380
+ if (getCsrfEnabled())
381
+ refreshCsrfToken(c);
323
382
  return c.json({ message: "Email verified" }, 200);
324
383
  });
325
384
  router.openapi(createRoute({
@@ -436,6 +495,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
436
495
  return c.json({ error: "Too many attempts. Try again later." }, 429);
437
496
  }
438
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
+ }
439
505
  // consumeResetToken atomically gets and deletes — prevents concurrent replay
440
506
  const entry = await consumeResetToken(token);
441
507
  if (!entry)
@@ -449,6 +515,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
449
515
  // Revoke all sessions so stolen JWTs can't stay valid after a reset
450
516
  const sessions = await getUserSessions(entry.userId);
451
517
  await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
518
+ emitSecurityEvent({ eventType: "auth.password.reset", severity: "info", timestamp: new Date().toISOString() });
452
519
  return c.json({ message: "Password reset successfully" }, 200);
453
520
  });
454
521
  }
@@ -458,7 +525,6 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
458
525
  if (refreshTokens) {
459
526
  const RefreshResponse = z.object({
460
527
  token: z.string().describe("New short-lived JWT access token."),
461
- refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
462
528
  userId: z.string().describe("Unique user ID."),
463
529
  }).openapi("RefreshResponse");
464
530
  router.openapi(createRoute({
@@ -497,7 +563,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
497
563
  const result = await AuthService.refresh(rt);
498
564
  setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
499
565
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
500
- return c.json(result, 200);
566
+ const { refreshToken: _rt, ...safeResult } = result;
567
+ return c.json(safeResult, 200);
501
568
  });
502
569
  }
503
570
  // ---------------------------------------------------------------------------
@@ -548,6 +615,78 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
548
615
  });
549
616
  }
550
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);
687
+ });
688
+ }
689
+ // ---------------------------------------------------------------------------
551
690
  // Session management
552
691
  // ---------------------------------------------------------------------------
553
692
  const SessionInfoSchema = z.object({
@@ -21,6 +21,9 @@ export const createJobsRouter = (config) => {
21
21
  const allowedQueues = new Set(config.allowedQueues ?? []);
22
22
  const authConfig = config.auth ?? "none";
23
23
  const scopeToUser = config.scopeToUser ?? false;
24
+ if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
25
+ throw new Error("[security] jobs.auth is required in production. Set jobs.auth or set unsafePublic: true.");
26
+ }
24
27
  // Determine if userAuth is involved (for scopeToUser and OpenAPI security schemes)
25
28
  const hasUserAuth = authConfig === "userAuth" || Array.isArray(authConfig);
26
29
  // Apply middleware based on config
@@ -141,7 +144,9 @@ export const createJobsRouter = (config) => {
141
144
  filteredJobs = jobs.filter((job) => job.data?.userId === userId);
142
145
  }
143
146
  const result = await Promise.all(filteredJobs.map(jobToResponse));
144
- return c.json({ jobs: result, total }, 200);
147
+ // NOTE: When scopeToUser is active, total is a page-local filtered count, not a
148
+ // globally accurate total. BullMQ does not support server-side user filtering.
149
+ return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
145
150
  });
146
151
  // ─── Dead letter queue ────────────────────────────────────────────────
147
152
  // Must be registered BEFORE getJobRoute so that the literal path segment
@@ -187,8 +192,15 @@ export const createJobsRouter = (config) => {
187
192
  dlqQueue.getWaiting(start, end),
188
193
  dlqQueue.getWaitingCount(),
189
194
  ]);
190
- const result = await Promise.all(jobs.map(jobToResponse));
191
- return c.json({ jobs: result, total }, 200);
195
+ let filteredJobs = jobs;
196
+ if (scopeToUser && hasUserAuth) {
197
+ const userId = c.get("authUserId");
198
+ filteredJobs = jobs.filter((job) => job.data?.userId === userId);
199
+ }
200
+ const result = await Promise.all(filteredJobs.map(jobToResponse));
201
+ // NOTE: When scopeToUser is active, total is a page-local filtered count, not a
202
+ // globally accurate total. BullMQ does not support server-side user filtering.
203
+ return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
192
204
  });
193
205
  // ─── Get job status ─────────────────────────────────────────────────────
194
206
  const getJobRoute = createRoute({
@@ -265,6 +277,12 @@ export const createJobsRouter = (config) => {
265
277
  const job = await queue.getJob(id);
266
278
  if (!job)
267
279
  return c.json({ error: "Job not found" }, 404);
280
+ if (scopeToUser && hasUserAuth) {
281
+ const userId = c.get("authUserId");
282
+ if (job.data?.userId !== userId) {
283
+ return c.json({ error: "Job not found" }, 404);
284
+ }
285
+ }
268
286
  const { logs, count } = await queue.getJobLogs(id);
269
287
  return c.json({ logs, count }, 200);
270
288
  });
@@ -0,0 +1,2 @@
1
+ import { Hono } from "hono";
2
+ export declare function createM2MRouter(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,72 @@
1
+ import { Hono } from "hono";
2
+ import { getAuthAdapter } from "../lib/authAdapter";
3
+ import { signToken } from "../lib/jwt";
4
+ import { HttpError } from "../lib/HttpError";
5
+ import { getM2MTokenExpiry } from "../lib/appConfig";
6
+ export function createM2MRouter() {
7
+ const router = new Hono();
8
+ /**
9
+ * POST /oauth/token
10
+ * OAuth 2.0 client_credentials grant
11
+ */
12
+ router.post("/oauth/token", async (c) => {
13
+ let body;
14
+ const contentType = c.req.header("content-type") ?? "";
15
+ if (contentType.includes("application/x-www-form-urlencoded")) {
16
+ const text = await c.req.text();
17
+ const params = new URLSearchParams(text);
18
+ body = {
19
+ grant_type: params.get("grant_type") ?? undefined,
20
+ client_id: params.get("client_id") ?? undefined,
21
+ client_secret: params.get("client_secret") ?? undefined,
22
+ scope: params.get("scope") ?? undefined,
23
+ };
24
+ }
25
+ else {
26
+ try {
27
+ body = await c.req.json();
28
+ }
29
+ catch {
30
+ throw new HttpError(400, "Invalid request body");
31
+ }
32
+ }
33
+ if (body.grant_type !== "client_credentials") {
34
+ throw new HttpError(400, "Unsupported grant type", "UNSUPPORTED_GRANT_TYPE");
35
+ }
36
+ const { client_id: clientId, client_secret: clientSecret } = body;
37
+ if (!clientId || !clientSecret) {
38
+ throw new HttpError(400, "client_id and client_secret are required");
39
+ }
40
+ const adapter = getAuthAdapter();
41
+ if (!adapter.getM2MClient) {
42
+ throw new HttpError(501, "M2M client credentials not supported by auth adapter");
43
+ }
44
+ const client = await adapter.getM2MClient(clientId);
45
+ if (!client) {
46
+ throw new HttpError(401, "Invalid client credentials");
47
+ }
48
+ const secretValid = await Bun.password.verify(clientSecret, client.clientSecretHash);
49
+ if (!secretValid) {
50
+ throw new HttpError(401, "Invalid client credentials");
51
+ }
52
+ // Validate requested scopes against client's allowed scopes
53
+ let grantedScopes = client.scopes;
54
+ if (body.scope) {
55
+ const requested = body.scope.split(" ");
56
+ const invalid = requested.filter((s) => !client.scopes.includes(s));
57
+ if (invalid.length > 0) {
58
+ throw new HttpError(400, `Scope not allowed: ${invalid.join(", ")}`, "INVALID_SCOPE");
59
+ }
60
+ grantedScopes = requested;
61
+ }
62
+ const expiry = getM2MTokenExpiry();
63
+ const token = await signToken({ sub: clientId, scope: grantedScopes.join(" ") }, expiry);
64
+ return c.json({
65
+ access_token: token,
66
+ token_type: "Bearer",
67
+ expires_in: expiry,
68
+ scope: grantedScopes.join(" "),
69
+ });
70
+ });
71
+ return router;
72
+ }
@@ -3,5 +3,6 @@ import type { AppEnv } from "../lib/context";
3
3
  export interface MetricsRouteConfig {
4
4
  auth?: "userAuth" | "none" | MiddlewareHandler<AppEnv>[];
5
5
  queues?: string[];
6
+ unsafePublic?: boolean;
6
7
  }
7
8
  export declare const createMetricsRouter: (config: MetricsRouteConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;
@@ -4,6 +4,9 @@ import { userAuth } from "../middleware/userAuth";
4
4
  export const createMetricsRouter = (config) => {
5
5
  const router = createRouter();
6
6
  const authConfig = config.auth ?? "none";
7
+ if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
8
+ throw new Error("[security] metrics.auth is required in production. Set metrics.auth or set unsafePublic: true.");
9
+ }
7
10
  // Apply auth middleware
8
11
  if (authConfig === "userAuth") {
9
12
  router.use("/metrics", userAuth);
@@ -8,10 +8,12 @@ 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
10
  import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getCsrfEnabled } from "../lib/appConfig";
11
+ import { setMfaVerifiedAt } from "../lib/session";
11
12
  import { refreshCsrfToken } from "../middleware/csrf";
12
13
  import { getAuthAdapter } from "../lib/authAdapter";
13
14
  import { trackAttempt } from "../lib/authRateLimit";
14
15
  import { getClientIp } from "../lib/clientIp";
16
+ import { emitSecurityEvent } from "../lib/securityEvents";
15
17
  const isProd = process.env.NODE_ENV === "production";
16
18
  const cookieOptions = (maxAge) => ({
17
19
  httpOnly: true,
@@ -105,6 +107,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
105
107
  const userId = c.get("authUserId");
106
108
  const { code } = c.req.valid("json");
107
109
  const recoveryCodes = await MfaService.verifySetup(userId, code);
110
+ emitSecurityEvent({ eventType: "auth.mfa.setup", severity: "info", timestamp: new Date().toISOString(), userId: c.get("authUserId") ?? undefined });
108
111
  return c.json({ message: "MFA enabled", recoveryCodes }, 200);
109
112
  });
110
113
  // ─── Verify (complete login after password) ───────────────────────────────
@@ -185,13 +188,17 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
185
188
  if (!valid && code) {
186
189
  valid = await MfaService.verifyRecoveryCode(userId, code);
187
190
  }
188
- if (!valid)
191
+ if (!valid) {
192
+ emitSecurityEvent({ eventType: "auth.mfa.verify.failure", severity: "warn", timestamp: new Date().toISOString() });
189
193
  return c.json({ error: "Invalid MFA code" }, 401);
194
+ }
190
195
  // Create session — reuse the service helper for refresh token support
191
196
  const result = await AuthService.createSessionForUser(userId, {
192
197
  ipAddress: getClientIp(c),
193
198
  userAgent: c.req.header("user-agent") ?? undefined,
194
199
  });
200
+ // Mark MFA as verified on the new session so step-up is satisfied immediately
201
+ await setMfaVerifiedAt(result.sessionId);
195
202
  const rtConfig = getRefreshTokenConfig();
196
203
  setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
197
204
  if (result.refreshToken) {
@@ -199,6 +206,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
199
206
  }
200
207
  if (getCsrfEnabled())
201
208
  refreshCsrfToken(c);
209
+ emitSecurityEvent({ eventType: "auth.mfa.verify.success", severity: "info", timestamp: new Date().toISOString() });
202
210
  return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
203
211
  });
204
212
  // ─── Disable MFA ──────────────────────────────────────────────────────────
@@ -25,6 +25,12 @@ const cookieOptions = (maxAge) => ({
25
25
  });
26
26
  const tags = ["OAuth"];
27
27
  const OAuthErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("OAuthErrorResponse");
28
+ // `postLoginRedirect` is always sourced from server-side config (passed into
29
+ // `createOAuthRouter` at startup) and is never derived from user-supplied input
30
+ // or OAuth callback query parameters. No runtime allowlist validation is required
31
+ // because the value is not attacker-controlled. The `auth.oauth.allowedRedirectUrls`
32
+ // config is available for consuming apps that want to enforce an explicit allowlist
33
+ // at the framework level if they ever pass a dynamic value here.
28
34
  const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
29
35
  const adapter = getAuthAdapter();
30
36
  if (!adapter.findOrCreateByProvider) {
@@ -0,0 +1,2 @@
1
+ import { Hono } from "hono";
2
+ export declare function createOidcRouter(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,29 @@
1
+ import { Hono } from "hono";
2
+ import { getJwks } from "../lib/jwks";
3
+ import { getOidcConfig } from "../lib/appConfig";
4
+ export function createOidcRouter() {
5
+ const router = new Hono();
6
+ router.get("/.well-known/openid-configuration", (c) => {
7
+ const config = getOidcConfig();
8
+ if (!config)
9
+ return c.json({ error: "OIDC not configured" }, 404);
10
+ const issuer = config.issuer;
11
+ const tokenEndpoint = config.tokenEndpoint ?? `${issuer}/oauth/token`;
12
+ return c.json({
13
+ issuer,
14
+ authorization_endpoint: `${issuer}/auth/oauth`,
15
+ token_endpoint: tokenEndpoint,
16
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
17
+ response_types_supported: ["code"],
18
+ subject_types_supported: ["public"],
19
+ id_token_signing_alg_values_supported: ["RS256"],
20
+ scopes_supported: config.scopes ?? ["openid"],
21
+ token_endpoint_auth_methods_supported: ["client_secret_post"],
22
+ claims_supported: ["sub", "iss", "aud", "exp", "iat"],
23
+ });
24
+ });
25
+ router.get("/.well-known/jwks.json", (c) => {
26
+ return c.json(getJwks());
27
+ });
28
+ return router;
29
+ }
@@ -0,0 +1 @@
1
+ export declare const createPasskeyRouter: () => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;