@lastshotlabs/bunshot 0.0.10 → 0.0.16

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 (100) hide show
  1. package/README.md +2510 -1580
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +107 -2
  8. package/dist/app.js +83 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +15 -5
  12. package/dist/index.js +10 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/createRoute.d.ts +61 -0
  22. package/dist/lib/createRoute.js +147 -0
  23. package/dist/lib/jwt.d.ts +1 -1
  24. package/dist/lib/jwt.js +2 -2
  25. package/dist/lib/mfaChallenge.d.ts +20 -0
  26. package/dist/lib/mfaChallenge.js +184 -0
  27. package/dist/lib/queue.d.ts +33 -0
  28. package/dist/lib/queue.js +98 -0
  29. package/dist/lib/roles.d.ts +4 -0
  30. package/dist/lib/roles.js +27 -0
  31. package/dist/lib/session.d.ts +12 -0
  32. package/dist/lib/session.js +163 -5
  33. package/dist/lib/tenant.d.ts +15 -0
  34. package/dist/lib/tenant.js +65 -0
  35. package/dist/lib/zodToMongoose.d.ts +38 -0
  36. package/dist/lib/zodToMongoose.js +84 -0
  37. package/dist/middleware/cacheResponse.js +4 -1
  38. package/dist/middleware/rateLimit.d.ts +2 -1
  39. package/dist/middleware/rateLimit.js +5 -2
  40. package/dist/middleware/requireRole.d.ts +14 -3
  41. package/dist/middleware/requireRole.js +46 -6
  42. package/dist/middleware/tenant.d.ts +5 -0
  43. package/dist/middleware/tenant.js +116 -0
  44. package/dist/models/AuthUser.d.ts +8 -0
  45. package/dist/models/AuthUser.js +8 -0
  46. package/dist/models/TenantRole.d.ts +15 -0
  47. package/dist/models/TenantRole.js +23 -0
  48. package/dist/routes/auth.d.ts +5 -3
  49. package/dist/routes/auth.js +253 -80
  50. package/dist/routes/jobs.d.ts +2 -0
  51. package/dist/routes/jobs.js +270 -0
  52. package/dist/routes/mfa.d.ts +1 -0
  53. package/dist/routes/mfa.js +409 -0
  54. package/dist/routes/oauth.js +107 -16
  55. package/dist/server.js +9 -0
  56. package/dist/services/auth.d.ts +21 -2
  57. package/dist/services/auth.js +97 -17
  58. package/dist/services/mfa.d.ts +37 -0
  59. package/dist/services/mfa.js +276 -0
  60. package/docs/sections/adding-middleware/full.md +35 -0
  61. package/docs/sections/adding-models/full.md +125 -0
  62. package/docs/sections/adding-models/overview.md +13 -0
  63. package/docs/sections/adding-routes/full.md +182 -0
  64. package/docs/sections/adding-routes/overview.md +23 -0
  65. package/docs/sections/auth-flow/full.md +456 -0
  66. package/docs/sections/auth-flow/overview.md +10 -0
  67. package/docs/sections/cli/full.md +30 -0
  68. package/docs/sections/configuration/full.md +135 -0
  69. package/docs/sections/configuration/overview.md +17 -0
  70. package/docs/sections/configuration-example/full.md +99 -0
  71. package/docs/sections/configuration-example/overview.md +30 -0
  72. package/docs/sections/documentation/full.md +171 -0
  73. package/docs/sections/environment-variables/full.md +55 -0
  74. package/docs/sections/exports/full.md +83 -0
  75. package/docs/sections/extending-context/full.md +59 -0
  76. package/docs/sections/header.md +3 -0
  77. package/docs/sections/installation/full.md +6 -0
  78. package/docs/sections/jobs/full.md +140 -0
  79. package/docs/sections/jobs/overview.md +15 -0
  80. package/docs/sections/mongodb-connections/full.md +45 -0
  81. package/docs/sections/mongodb-connections/overview.md +7 -0
  82. package/docs/sections/multi-tenancy/full.md +62 -0
  83. package/docs/sections/multi-tenancy/overview.md +15 -0
  84. package/docs/sections/oauth/full.md +119 -0
  85. package/docs/sections/oauth/overview.md +16 -0
  86. package/docs/sections/package-development/full.md +7 -0
  87. package/docs/sections/peer-dependencies/full.md +43 -0
  88. package/docs/sections/quick-start/full.md +43 -0
  89. package/docs/sections/response-caching/full.md +115 -0
  90. package/docs/sections/response-caching/overview.md +13 -0
  91. package/docs/sections/roles/full.md +136 -0
  92. package/docs/sections/roles/overview.md +12 -0
  93. package/docs/sections/running-without-redis/full.md +16 -0
  94. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  95. package/docs/sections/stack/full.md +10 -0
  96. package/docs/sections/websocket/full.md +100 -0
  97. package/docs/sections/websocket/overview.md +5 -0
  98. package/docs/sections/websocket-rooms/full.md +97 -0
  99. package/docs/sections/websocket-rooms/overview.md +5 -0
  100. package/package.json +19 -10
@@ -0,0 +1,409 @@
1
+ import { createRoute, withSecurity } from "../lib/createRoute";
2
+ import { z } from "zod";
3
+ import { setCookie } from "hono/cookie";
4
+ import { createRouter } from "../lib/context";
5
+ import { userAuth } from "../middleware/userAuth";
6
+ import * as MfaService from "../services/mfa";
7
+ import * as AuthService from "../services/auth";
8
+ import { consumeMfaChallenge, replaceMfaChallengeOtp } from "../lib/mfaChallenge";
9
+ import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
10
+ import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig } from "../lib/appConfig";
11
+ import { getAuthAdapter } from "../lib/authAdapter";
12
+ const isProd = process.env.NODE_ENV === "production";
13
+ const cookieOptions = (maxAge) => ({
14
+ httpOnly: true,
15
+ secure: isProd,
16
+ sameSite: "Lax",
17
+ path: "/",
18
+ maxAge: maxAge ?? 60 * 60 * 24 * 7,
19
+ });
20
+ const tags = ["MFA"];
21
+ const ErrorResponse = z.object({ error: z.string() }).openapi("MfaErrorResponse");
22
+ export const createMfaRouter = () => {
23
+ const router = createRouter();
24
+ // All MFA setup/management routes require auth
25
+ router.use("/auth/mfa/setup", userAuth);
26
+ router.use("/auth/mfa/verify-setup", userAuth);
27
+ router.use("/auth/mfa", userAuth);
28
+ router.use("/auth/mfa/recovery-codes", userAuth);
29
+ router.use("/auth/mfa/email-otp/enable", userAuth);
30
+ router.use("/auth/mfa/email-otp/verify-setup", userAuth);
31
+ router.use("/auth/mfa/email-otp", userAuth);
32
+ router.use("/auth/mfa/methods", userAuth);
33
+ // ─── Setup ────────────────────────────────────────────────────────────────
34
+ router.openapi(withSecurity(createRoute({
35
+ method: "post",
36
+ path: "/auth/mfa/setup",
37
+ summary: "Initiate MFA setup",
38
+ description: "Generates a TOTP secret and returns the otpauth URI for QR code scanning. The user must confirm setup by verifying a code via POST /auth/mfa/verify-setup.",
39
+ tags,
40
+ responses: {
41
+ 200: {
42
+ content: {
43
+ "application/json": {
44
+ schema: z.object({
45
+ secret: z.string().describe("Base32-encoded TOTP secret."),
46
+ uri: z.string().describe("otpauth:// URI for QR code generation."),
47
+ }),
48
+ },
49
+ },
50
+ description: "TOTP secret generated. Scan the QR code with an authenticator app.",
51
+ },
52
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
53
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
54
+ },
55
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
56
+ const userId = c.get("authUserId");
57
+ const result = await MfaService.setupMfa(userId);
58
+ return c.json(result, 200);
59
+ });
60
+ // ─── Verify Setup ─────────────────────────────────────────────────────────
61
+ router.openapi(withSecurity(createRoute({
62
+ method: "post",
63
+ path: "/auth/mfa/verify-setup",
64
+ summary: "Confirm MFA setup",
65
+ description: "Verifies a TOTP code from the authenticator app and enables MFA. Returns one-time recovery codes that should be stored securely. If email OTP was previously enabled, recovery codes are regenerated.",
66
+ tags,
67
+ request: {
68
+ body: {
69
+ content: {
70
+ "application/json": {
71
+ schema: z.object({
72
+ code: z.string().length(6).describe("6-digit TOTP code from the authenticator app."),
73
+ }),
74
+ },
75
+ },
76
+ },
77
+ },
78
+ responses: {
79
+ 200: {
80
+ content: {
81
+ "application/json": {
82
+ schema: z.object({
83
+ message: z.string(),
84
+ recoveryCodes: z.array(z.string()).describe("One-time recovery codes. Store these securely — they cannot be shown again."),
85
+ }),
86
+ },
87
+ },
88
+ description: "MFA enabled successfully.",
89
+ },
90
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "MFA setup not initiated." },
91
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid TOTP code or no valid session." },
92
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
93
+ },
94
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
95
+ const userId = c.get("authUserId");
96
+ const { code } = c.req.valid("json");
97
+ const recoveryCodes = await MfaService.verifySetup(userId, code);
98
+ return c.json({ message: "MFA enabled", recoveryCodes }, 200);
99
+ });
100
+ // ─── Verify (complete login after password) ───────────────────────────────
101
+ const MfaLoginResponse = z.object({
102
+ token: z.string().describe("JWT session token."),
103
+ userId: z.string().describe("Unique user ID."),
104
+ refreshToken: z.string().optional().describe("Refresh token (when configured)."),
105
+ }).openapi("MfaLoginResponse");
106
+ router.openapi(createRoute({
107
+ method: "post",
108
+ path: "/auth/mfa/verify",
109
+ 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.",
111
+ tags,
112
+ request: {
113
+ body: {
114
+ content: {
115
+ "application/json": {
116
+ schema: z.object({
117
+ 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."),
120
+ }),
121
+ },
122
+ },
123
+ },
124
+ },
125
+ responses: {
126
+ 200: { content: { "application/json": { schema: MfaLoginResponse } }, description: "MFA verified. Session created." },
127
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired MFA token, or invalid code." },
128
+ },
129
+ }), async (c) => {
130
+ const { mfaToken, code, method } = c.req.valid("json");
131
+ const challenge = await consumeMfaChallenge(mfaToken);
132
+ if (!challenge)
133
+ return c.json({ error: "Invalid or expired MFA token" }, 401);
134
+ const { userId, emailOtpHash } = challenge;
135
+ let valid = false;
136
+ if (method === "totp") {
137
+ // Only try TOTP
138
+ valid = await MfaService.verifyTotp(userId, code);
139
+ }
140
+ else if (method === "emailOtp") {
141
+ // Only try email OTP
142
+ if (emailOtpHash)
143
+ valid = MfaService.verifyEmailOtp(emailOtpHash, code);
144
+ }
145
+ else {
146
+ // Auto-detect: use emailOtpHash presence to pick order
147
+ if (emailOtpHash) {
148
+ // Email OTP first, then TOTP, then recovery
149
+ valid = MfaService.verifyEmailOtp(emailOtpHash, code);
150
+ if (!valid)
151
+ valid = await MfaService.verifyTotp(userId, code);
152
+ }
153
+ else {
154
+ // TOTP first
155
+ valid = await MfaService.verifyTotp(userId, code);
156
+ }
157
+ }
158
+ // Always try recovery code as fallback
159
+ if (!valid) {
160
+ valid = await MfaService.verifyRecoveryCode(userId, code);
161
+ }
162
+ if (!valid)
163
+ return c.json({ error: "Invalid MFA code" }, 401);
164
+ // Create session — reuse the service helper for refresh token support
165
+ const result = await AuthService.createSessionForUser(userId, {
166
+ ipAddress: (c.req.header("x-forwarded-for")?.split(",")[0]?.trim()) ?? c.req.header("x-real-ip") ?? undefined,
167
+ userAgent: c.req.header("user-agent") ?? undefined,
168
+ });
169
+ const rtConfig = getRefreshTokenConfig();
170
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
171
+ if (result.refreshToken) {
172
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
173
+ }
174
+ return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
175
+ });
176
+ // ─── Disable MFA ──────────────────────────────────────────────────────────
177
+ router.openapi(withSecurity(createRoute({
178
+ method: "delete",
179
+ path: "/auth/mfa",
180
+ summary: "Disable MFA",
181
+ description: "Disables MFA for the authenticated user. Requires a valid TOTP code to confirm.",
182
+ tags,
183
+ request: {
184
+ body: {
185
+ content: {
186
+ "application/json": {
187
+ schema: z.object({
188
+ code: z.string().length(6).describe("6-digit TOTP code to confirm disabling MFA."),
189
+ }),
190
+ },
191
+ },
192
+ },
193
+ },
194
+ responses: {
195
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "MFA disabled." },
196
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid TOTP code or no valid session." },
197
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
198
+ },
199
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
200
+ const userId = c.get("authUserId");
201
+ const { code } = c.req.valid("json");
202
+ await MfaService.disableMfa(userId, code);
203
+ return c.json({ message: "MFA disabled" }, 200);
204
+ });
205
+ // ─── Regenerate Recovery Codes ────────────────────────────────────────────
206
+ router.openapi(withSecurity(createRoute({
207
+ method: "post",
208
+ path: "/auth/mfa/recovery-codes",
209
+ summary: "Regenerate recovery codes",
210
+ description: "Generates new recovery codes, invalidating all previous ones. Requires a valid TOTP code to confirm.",
211
+ tags,
212
+ request: {
213
+ body: {
214
+ content: {
215
+ "application/json": {
216
+ schema: z.object({
217
+ code: z.string().length(6).describe("6-digit TOTP code to confirm regeneration."),
218
+ }),
219
+ },
220
+ },
221
+ },
222
+ },
223
+ responses: {
224
+ 200: {
225
+ content: {
226
+ "application/json": {
227
+ schema: z.object({
228
+ recoveryCodes: z.array(z.string()).describe("New one-time recovery codes."),
229
+ }),
230
+ },
231
+ },
232
+ description: "New recovery codes generated.",
233
+ },
234
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid TOTP code or no valid session." },
235
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
236
+ },
237
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
238
+ const userId = c.get("authUserId");
239
+ const { code } = c.req.valid("json");
240
+ const recoveryCodes = await MfaService.regenerateRecoveryCodes(userId, code);
241
+ return c.json({ recoveryCodes }, 200);
242
+ });
243
+ // ─── Email OTP: Enable (initiate) ────────────────────────────────────────
244
+ router.openapi(withSecurity(createRoute({
245
+ method: "post",
246
+ path: "/auth/mfa/email-otp/enable",
247
+ summary: "Initiate email OTP setup",
248
+ description: "Sends a verification code to the user's email to confirm email OTP setup. Confirm via POST /auth/mfa/email-otp/verify-setup.",
249
+ tags,
250
+ responses: {
251
+ 200: {
252
+ content: {
253
+ "application/json": {
254
+ schema: z.object({
255
+ message: z.string(),
256
+ setupToken: z.string().describe("Setup challenge token. Pass to POST /auth/mfa/email-otp/verify-setup with the code."),
257
+ }),
258
+ },
259
+ },
260
+ description: "Verification code sent to email.",
261
+ },
262
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "No email address on account." },
263
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
264
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Email OTP is not configured." },
265
+ },
266
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
267
+ const userId = c.get("authUserId");
268
+ const setupToken = await MfaService.initiateEmailOtp(userId);
269
+ return c.json({ message: "Verification code sent", setupToken }, 200);
270
+ });
271
+ // ─── Email OTP: Verify Setup ─────────────────────────────────────────────
272
+ router.openapi(withSecurity(createRoute({
273
+ method: "post",
274
+ path: "/auth/mfa/email-otp/verify-setup",
275
+ summary: "Confirm email OTP setup",
276
+ description: "Verifies the code sent during email OTP initiation and enables email OTP as an MFA method. Returns recovery codes (new or regenerated if another MFA method was already active).",
277
+ tags,
278
+ request: {
279
+ body: {
280
+ content: {
281
+ "application/json": {
282
+ schema: z.object({
283
+ setupToken: z.string().describe("Setup challenge token from POST /auth/mfa/email-otp/enable."),
284
+ code: z.string().describe("Verification code sent to email."),
285
+ }),
286
+ },
287
+ },
288
+ },
289
+ },
290
+ responses: {
291
+ 200: {
292
+ content: {
293
+ "application/json": {
294
+ schema: z.object({
295
+ message: z.string(),
296
+ recoveryCodes: z.array(z.string()).optional().describe("Recovery codes (always returned when email OTP is enabled)."),
297
+ }),
298
+ },
299
+ },
300
+ description: "Email OTP enabled.",
301
+ },
302
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid setup token or code." },
303
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
304
+ },
305
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
306
+ const userId = c.get("authUserId");
307
+ const { setupToken, code } = c.req.valid("json");
308
+ const recoveryCodes = await MfaService.confirmEmailOtp(userId, setupToken, code);
309
+ return c.json({ message: "Email OTP enabled", recoveryCodes: recoveryCodes ?? undefined }, 200);
310
+ });
311
+ // ─── Email OTP: Disable ──────────────────────────────────────────────────
312
+ router.openapi(withSecurity(createRoute({
313
+ method: "delete",
314
+ path: "/auth/mfa/email-otp",
315
+ summary: "Disable email OTP",
316
+ description: "Disables email OTP for the authenticated user. Requires a TOTP code if TOTP is also enabled, or a password if email OTP is the only MFA method.",
317
+ tags,
318
+ request: {
319
+ body: {
320
+ content: {
321
+ "application/json": {
322
+ schema: z.object({
323
+ code: z.string().optional().describe("6-digit TOTP code (required when TOTP is also enabled)."),
324
+ password: z.string().optional().describe("Account password (required when email OTP is the only MFA method)."),
325
+ }),
326
+ },
327
+ },
328
+ },
329
+ },
330
+ responses: {
331
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email OTP disabled." },
332
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Missing required verification." },
333
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code/password or no valid session." },
334
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
335
+ },
336
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
337
+ const userId = c.get("authUserId");
338
+ const { code, password } = c.req.valid("json");
339
+ await MfaService.disableEmailOtp(userId, { code, password });
340
+ return c.json({ message: "Email OTP disabled" }, 200);
341
+ });
342
+ // ─── Resend Email OTP ────────────────────────────────────────────────────
343
+ router.openapi(createRoute({
344
+ method: "post",
345
+ path: "/auth/mfa/resend",
346
+ summary: "Resend email OTP code",
347
+ description: "Generates and sends a new email OTP code for the given MFA challenge. Rate-limited to 3 resends per challenge. Does not extend the challenge beyond 3x the original TTL.",
348
+ tags,
349
+ request: {
350
+ body: {
351
+ content: {
352
+ "application/json": {
353
+ schema: z.object({
354
+ mfaToken: z.string().describe("MFA challenge token from the login response."),
355
+ }),
356
+ },
357
+ },
358
+ },
359
+ },
360
+ responses: {
361
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Code sent." },
362
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email OTP not configured." },
363
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired MFA token." },
364
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Maximum resends reached." },
365
+ },
366
+ }), async (c) => {
367
+ const { mfaToken } = c.req.valid("json");
368
+ const emailOtpConfig = getMfaEmailOtpConfig();
369
+ if (!emailOtpConfig)
370
+ return c.json({ error: "Email OTP is not configured" }, 400);
371
+ const { code, hash } = MfaService.generateEmailOtpCode();
372
+ const result = await replaceMfaChallengeOtp(mfaToken, hash);
373
+ if (!result)
374
+ return c.json({ error: "Invalid/expired MFA token or maximum resends reached" }, 401);
375
+ // Get user email and send
376
+ const adapter = getAuthAdapter();
377
+ const user = adapter.getUser ? await adapter.getUser(result.userId) : null;
378
+ if (user?.email) {
379
+ await emailOtpConfig.onSend(user.email, code);
380
+ }
381
+ return c.json({ message: "Code sent" }, 200);
382
+ });
383
+ // ─── Get MFA Methods ────────────────────────────────────────────────────
384
+ router.openapi(withSecurity(createRoute({
385
+ method: "get",
386
+ path: "/auth/mfa/methods",
387
+ summary: "Get enabled MFA methods",
388
+ description: "Returns the MFA methods currently enabled for the authenticated user.",
389
+ tags,
390
+ responses: {
391
+ 200: {
392
+ content: {
393
+ "application/json": {
394
+ schema: z.object({
395
+ methods: z.array(z.string()).describe("Enabled MFA methods (e.g., 'totp', 'emailOtp')."),
396
+ }),
397
+ },
398
+ },
399
+ description: "Enabled MFA methods.",
400
+ },
401
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
402
+ },
403
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
404
+ const userId = c.get("authUserId");
405
+ const methods = await MfaService.getMfaMethods(userId);
406
+ return c.json({ methods }, 200);
407
+ });
408
+ return router;
409
+ };
@@ -1,22 +1,26 @@
1
+ import { createRoute, withSecurity } from "../lib/createRoute";
1
2
  import { createRouter } from "../lib/context";
2
3
  import { setCookie } from "hono/cookie";
3
4
  import { decodeIdToken } from "arctic";
5
+ import { z } from "zod";
4
6
  import { getGoogle, getApple, storeOAuthState, consumeOAuthState, generateState, generateCodeVerifier, } from "../lib/oauth";
5
7
  import { getAuthAdapter } from "../lib/authAdapter";
6
8
  import { HttpError } from "../lib/HttpError";
7
9
  import { signToken } from "../lib/jwt";
8
- import { createSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
9
- import { COOKIE_TOKEN } from "../lib/constants";
10
+ import { createSession, getActiveSessionCount, evictOldestSession, setRefreshToken } from "../lib/session";
11
+ import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
10
12
  import { userAuth } from "../middleware/userAuth";
11
- import { getDefaultRole, getMaxSessions } from "../lib/appConfig";
13
+ import { getDefaultRole, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry } from "../lib/appConfig";
12
14
  const isProd = process.env.NODE_ENV === "production";
13
- const cookieOptions = {
15
+ const cookieOptions = (maxAge) => ({
14
16
  httpOnly: true,
15
17
  secure: isProd,
16
18
  sameSite: "Lax",
17
19
  path: "/",
18
- maxAge: 60 * 60 * 24 * 7,
19
- };
20
+ maxAge: maxAge ?? 60 * 60 * 24 * 7,
21
+ });
22
+ const tags = ["OAuth"];
23
+ const OAuthErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("OAuthErrorResponse");
20
24
  const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
21
25
  const adapter = getAuthAdapter();
22
26
  if (!adapter.findOrCreateByProvider) {
@@ -37,7 +41,9 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
37
41
  await adapter.setRoles(user.id, [role]);
38
42
  }
39
43
  const sessionId = crypto.randomUUID();
40
- const token = await signToken(user.id, sessionId);
44
+ const rtConfig = getRefreshTokenConfig();
45
+ const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
46
+ const token = await signToken(user.id, sessionId, expirySeconds);
41
47
  const xff = c.req.header("x-forwarded-for");
42
48
  const metadata = {
43
49
  ipAddress: (xff ? xff.split(",")[0]?.trim() : undefined) ?? c.req.header("x-real-ip") ?? undefined,
@@ -47,7 +53,13 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
47
53
  await evictOldestSession(user.id);
48
54
  }
49
55
  await createSession(user.id, token, sessionId, metadata);
50
- setCookie(c, COOKIE_TOKEN, token, cookieOptions);
56
+ setCookie(c, COOKIE_TOKEN, token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
57
+ let refreshTokenValue;
58
+ if (rtConfig) {
59
+ refreshTokenValue = crypto.randomUUID();
60
+ await setRefreshToken(sessionId, refreshTokenValue);
61
+ setCookie(c, COOKIE_REFRESH_TOKEN, refreshTokenValue, cookieOptions(getRefreshTokenExpiry()));
62
+ }
51
63
  // Append token to redirect so non-browser clients (mobile deep links) can extract it.
52
64
  // Browser apps can safely ignore the query param.
53
65
  try {
@@ -68,15 +80,41 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
68
80
  const router = createRouter();
69
81
  // ─── Google ───────────────────────────────────────────────────────────────
70
82
  if (providers.includes("google")) {
71
- router.get("/auth/google", async (c) => {
83
+ router.openapi(createRoute({
84
+ method: "get",
85
+ path: "/auth/google",
86
+ summary: "Initiate Google OAuth",
87
+ description: "Redirects the user to Google's consent screen to begin the OAuth login flow. After the user authorizes, Google redirects back to `/auth/google/callback`.",
88
+ tags,
89
+ responses: {
90
+ 302: { description: "Redirect to Google's OAuth consent screen." },
91
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
92
+ },
93
+ }), async (c) => {
72
94
  const state = generateState();
73
95
  const codeVerifier = generateCodeVerifier();
74
96
  await storeOAuthState(state, codeVerifier);
75
97
  const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
76
98
  return c.redirect(url.toString());
77
99
  });
78
- router.get("/auth/google/callback", async (c) => {
79
- const { code, state } = c.req.query();
100
+ router.openapi(createRoute({
101
+ method: "get",
102
+ path: "/auth/google/callback",
103
+ summary: "Google OAuth callback",
104
+ description: "Handles the redirect from Google after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
105
+ tags,
106
+ request: {
107
+ query: z.object({
108
+ code: z.string().describe("Authorization code from Google."),
109
+ state: z.string().describe("OAuth state parameter for CSRF protection."),
110
+ }),
111
+ },
112
+ responses: {
113
+ 302: { description: "Redirect to the post-login URL with session token." },
114
+ 400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
115
+ },
116
+ }), async (c) => {
117
+ const { code, state } = c.req.valid("query");
80
118
  if (!code || !state)
81
119
  return c.json({ error: "Invalid callback" }, 400);
82
120
  const stored = await consumeOAuthState(state);
@@ -96,14 +134,36 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
96
134
  }
97
135
  return finishOAuth(c, "google", info.sub, { email: info.email, name: info.name, avatarUrl: info.picture }, postLoginRedirect);
98
136
  });
99
- router.get("/auth/google/link", userAuth, async (c) => {
137
+ router.use("/auth/google/link", userAuth);
138
+ router.openapi(withSecurity(createRoute({
139
+ method: "get",
140
+ path: "/auth/google/link",
141
+ summary: "Link Google account",
142
+ description: "Initiates an OAuth flow to link a Google account to the authenticated user. Requires a valid session. Redirects to Google's consent screen.",
143
+ tags,
144
+ responses: {
145
+ 302: { description: "Redirect to Google's OAuth consent screen." },
146
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
147
+ },
148
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
100
149
  const state = generateState();
101
150
  const codeVerifier = generateCodeVerifier();
102
151
  await storeOAuthState(state, codeVerifier, c.get("authUserId"));
103
152
  const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
104
153
  return c.redirect(url.toString());
105
154
  });
106
- router.delete("/auth/google/link", userAuth, async (c) => {
155
+ router.openapi(withSecurity(createRoute({
156
+ method: "delete",
157
+ path: "/auth/google/link",
158
+ summary: "Unlink Google account",
159
+ description: "Removes the linked Google OAuth account from the authenticated user. Requires a valid session.",
160
+ tags,
161
+ responses: {
162
+ 204: { description: "Google account unlinked successfully." },
163
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
164
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
165
+ },
166
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
107
167
  const adapter = getAuthAdapter();
108
168
  if (!adapter.unlinkProvider) {
109
169
  return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
@@ -114,14 +174,34 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
114
174
  }
115
175
  // ─── Apple ────────────────────────────────────────────────────────────────
116
176
  if (providers.includes("apple")) {
117
- router.get("/auth/apple", async (c) => {
177
+ router.openapi(createRoute({
178
+ method: "get",
179
+ path: "/auth/apple",
180
+ summary: "Initiate Apple OAuth",
181
+ description: "Redirects the user to Apple's sign-in page to begin the OAuth login flow. After the user authorizes, Apple posts back to `/auth/apple/callback`.",
182
+ tags,
183
+ responses: {
184
+ 302: { description: "Redirect to Apple's OAuth sign-in page." },
185
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
186
+ },
187
+ }), async (c) => {
118
188
  const state = generateState();
119
189
  await storeOAuthState(state);
120
190
  const url = getApple().createAuthorizationURL(state, ["name", "email"]);
121
191
  return c.redirect(url.toString());
122
192
  });
123
193
  // Apple sends a POST with form data to the callback URL
124
- router.post("/auth/apple/callback", async (c) => {
194
+ router.openapi(createRoute({
195
+ method: "post",
196
+ path: "/auth/apple/callback",
197
+ summary: "Apple OAuth callback",
198
+ description: "Handles the POST redirect from Apple after user authorization. Apple sends form-encoded data containing the authorization code and state. Validates the OAuth state, exchanges the code for tokens, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
199
+ tags,
200
+ responses: {
201
+ 302: { description: "Redirect to the post-login URL with session token." },
202
+ 400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
203
+ },
204
+ }), async (c) => {
125
205
  const form = await c.req.formData();
126
206
  const code = form.get("code");
127
207
  const state = form.get("state");
@@ -148,7 +228,18 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
148
228
  : undefined;
149
229
  return finishOAuth(c, "apple", claims.sub, { email: claims.email, name }, postLoginRedirect);
150
230
  });
151
- router.get("/auth/apple/link", userAuth, async (c) => {
231
+ router.use("/auth/apple/link", userAuth);
232
+ router.openapi(withSecurity(createRoute({
233
+ method: "get",
234
+ path: "/auth/apple/link",
235
+ summary: "Link Apple account",
236
+ description: "Initiates an OAuth flow to link an Apple account to the authenticated user. Requires a valid session. Redirects to Apple's sign-in page.",
237
+ tags,
238
+ responses: {
239
+ 302: { description: "Redirect to Apple's OAuth sign-in page." },
240
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
241
+ },
242
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
152
243
  const state = generateState();
153
244
  await storeOAuthState(state, undefined, c.get("authUserId"));
154
245
  const url = getApple().createAuthorizationURL(state, ["name", "email"]);
package/dist/server.js CHANGED
@@ -46,6 +46,15 @@ export const createServer = async (config) => {
46
46
  for await (const file of glob.scan({ cwd: workersDir })) {
47
47
  await import(`${workersDir}/${file}`);
48
48
  }
49
+ // Clean up ghost cron schedulers after all workers are loaded
50
+ try {
51
+ const { getRegisteredCronNames, cleanupStaleSchedulers } = await import("./lib/queue");
52
+ const activeNames = [...getRegisteredCronNames()];
53
+ if (activeNames.length > 0) {
54
+ await cleanupStaleSchedulers(activeNames);
55
+ }
56
+ }
57
+ catch { /* bullmq not installed or no cron workers */ }
49
58
  }
50
59
  log(`[server] running at http://localhost:${server.port}`);
51
60
  log(`[server] API docs at http://localhost:${server.port}/docs`);
@@ -1,7 +1,26 @@
1
1
  import type { SessionMetadata } from "../lib/session";
2
- export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<string>;
3
- export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
2
+ export interface AuthResult {
4
3
  token: string;
4
+ userId: string;
5
+ email?: string;
5
6
  emailVerified?: boolean;
7
+ googleLinked?: boolean;
8
+ refreshToken?: string;
9
+ mfaRequired?: boolean;
10
+ mfaToken?: string;
11
+ mfaMethods?: string[];
12
+ }
13
+ /** Create a session for a user (used internally and by MFA verify). */
14
+ export declare const createSessionForUser: (userId: string, metadata?: SessionMetadata) => Promise<{
15
+ token: string;
16
+ refreshToken?: string;
17
+ }>;
18
+ export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
19
+ export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
20
+ export declare const refresh: (refreshTokenValue: string) => Promise<{
21
+ token: string;
22
+ refreshToken: string;
23
+ userId: string;
6
24
  }>;
25
+ export declare const deleteAccount: (userId: string, password?: string) => Promise<void>;
7
26
  export declare const logout: (token: string | null) => Promise<void>;