@lastshotlabs/bunshot 0.0.13 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2816 -1747
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +177 -2
- package/dist/adapters/mongoAuth.js +94 -0
- package/dist/adapters/sqliteAuth.d.ts +9 -0
- package/dist/adapters/sqliteAuth.js +190 -2
- package/dist/app.d.ts +120 -2
- package/dist/app.js +104 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +24 -8
- package/dist/index.js +15 -5
- package/dist/lib/appConfig.d.ts +81 -0
- package/dist/lib/appConfig.js +30 -0
- package/dist/lib/authAdapter.d.ts +54 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +19 -6
- package/dist/lib/mfaChallenge.d.ts +42 -0
- package/dist/lib/mfaChallenge.js +293 -0
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +165 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +18 -3
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +7 -5
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +17 -0
- package/dist/models/AuthUser.js +17 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +173 -30
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +5 -0
- package/dist/routes/mfa.js +616 -0
- package/dist/routes/oauth.js +378 -23
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +19 -3
- package/dist/services/auth.d.ts +18 -5
- package/dist/services/auth.js +112 -18
- package/dist/services/mfa.d.ts +84 -0
- package/dist/services/mfa.js +543 -0
- package/dist/ws/index.js +3 -2
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +634 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +155 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +117 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +92 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +66 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +189 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +47 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +117 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +101 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +30 -9
package/dist/routes/auth.js
CHANGED
|
@@ -2,34 +2,40 @@ import { createRoute, withSecurity } from "../lib/createRoute";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
4
|
import * as AuthService from "../services/auth";
|
|
5
|
-
import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
|
|
6
|
-
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
5
|
+
import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
|
|
6
|
+
import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
|
|
7
7
|
import { userAuth } from "../middleware/userAuth";
|
|
8
8
|
import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
9
9
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
10
10
|
import { createRouter } from "../lib/context";
|
|
11
11
|
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
12
|
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
13
|
-
import {
|
|
13
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
|
|
14
|
+
import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
|
|
15
|
+
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
16
|
+
import { getClientIp } from "../lib/clientIp";
|
|
14
17
|
const isProd = process.env.NODE_ENV === "production";
|
|
15
18
|
const TokenResponse = z.object({
|
|
16
|
-
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
|
|
19
|
+
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
17
20
|
userId: z.string().describe("Unique user ID."),
|
|
18
21
|
email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
|
|
19
22
|
emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
|
|
20
23
|
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
|
|
24
|
+
refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
|
|
25
|
+
mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
|
|
26
|
+
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')."),
|
|
21
28
|
}).openapi("TokenResponse");
|
|
22
29
|
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
23
30
|
const tags = ["Auth"];
|
|
24
|
-
const cookieOptions = {
|
|
31
|
+
const cookieOptions = (maxAge) => ({
|
|
25
32
|
httpOnly: true,
|
|
26
33
|
secure: isProd,
|
|
27
34
|
sameSite: "Lax",
|
|
28
35
|
path: "/",
|
|
29
|
-
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
30
|
-
};
|
|
31
|
-
const
|
|
32
|
-
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
|
|
36
|
+
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
37
|
+
});
|
|
38
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
33
39
|
const router = createRouter();
|
|
34
40
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
35
41
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -56,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
56
62
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
57
63
|
},
|
|
58
64
|
}), async (c) => {
|
|
59
|
-
const ip =
|
|
65
|
+
const ip = getClientIp(c);
|
|
60
66
|
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
61
67
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
62
68
|
}
|
|
@@ -67,7 +73,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
67
73
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
68
74
|
};
|
|
69
75
|
const result = await AuthService.register(identifier, body.password, metadata);
|
|
70
|
-
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
76
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
77
|
+
if (result.refreshToken) {
|
|
78
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
79
|
+
}
|
|
80
|
+
if (getCsrfEnabled())
|
|
81
|
+
refreshCsrfToken(c);
|
|
71
82
|
return c.json(result, 201);
|
|
72
83
|
});
|
|
73
84
|
router.openapi(createRoute({
|
|
@@ -91,13 +102,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
91
102
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
92
103
|
}
|
|
93
104
|
const metadata = {
|
|
94
|
-
ipAddress:
|
|
105
|
+
ipAddress: getClientIp(c),
|
|
95
106
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
96
107
|
};
|
|
97
108
|
try {
|
|
98
109
|
const result = await AuthService.login(identifier, body.password, metadata);
|
|
99
110
|
await bustAuthLimit(limitKey); // success — clear failure count
|
|
100
|
-
|
|
111
|
+
if (!result.mfaRequired) {
|
|
112
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
113
|
+
if (result.refreshToken) {
|
|
114
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
115
|
+
}
|
|
116
|
+
if (getCsrfEnabled())
|
|
117
|
+
refreshCsrfToken(c);
|
|
118
|
+
}
|
|
101
119
|
return c.json(result, 200);
|
|
102
120
|
}
|
|
103
121
|
catch (err) {
|
|
@@ -135,6 +153,74 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
135
153
|
const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
136
154
|
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
137
155
|
});
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Account deletion
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
const deleteAccountOpts = { windowMs: rateLimit?.deleteAccount?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.deleteAccount?.max ?? 3 };
|
|
160
|
+
router.openapi(withSecurity(createRoute({
|
|
161
|
+
method: "delete",
|
|
162
|
+
path: "/auth/me",
|
|
163
|
+
summary: "Delete account",
|
|
164
|
+
description: "Permanently deletes the authenticated user's account. Requires password confirmation for credential accounts. MFA is not required — the password serves as the identity check. Revokes all active sessions.",
|
|
165
|
+
tags,
|
|
166
|
+
request: {
|
|
167
|
+
body: {
|
|
168
|
+
content: {
|
|
169
|
+
"application/json": {
|
|
170
|
+
schema: z.object({
|
|
171
|
+
password: z.string().optional().describe("Current password. Required for credential accounts, optional for OAuth-only accounts."),
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
description: "Password confirmation.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
responses: {
|
|
179
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deleted." },
|
|
180
|
+
202: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion has been scheduled." },
|
|
181
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Password is required for credential accounts." },
|
|
182
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid password or no valid session." },
|
|
183
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many deletion attempts. Try again later." },
|
|
184
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support deleteUser." },
|
|
185
|
+
},
|
|
186
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
187
|
+
const authUserId = c.get("authUserId");
|
|
188
|
+
if (await trackAttempt(`deleteaccount:${authUserId}`, deleteAccountOpts)) {
|
|
189
|
+
return c.json({ error: "Too many deletion attempts. Try again later." }, 429);
|
|
190
|
+
}
|
|
191
|
+
const adapter = getAuthAdapter();
|
|
192
|
+
if (!adapter.deleteUser) {
|
|
193
|
+
return c.json({ error: "Auth adapter does not support deleteUser" }, 501);
|
|
194
|
+
}
|
|
195
|
+
const { password } = c.req.valid("json");
|
|
196
|
+
// Verify password for credential accounts
|
|
197
|
+
if (password) {
|
|
198
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
199
|
+
const email = user?.email;
|
|
200
|
+
if (email) {
|
|
201
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
202
|
+
const found = await findFn(email);
|
|
203
|
+
if (found && !(await Bun.password.verify(password, found.passwordHash))) {
|
|
204
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (adapter.hasPassword && await adapter.hasPassword(authUserId)) {
|
|
209
|
+
return c.json({ error: "Password is required to delete a credential account" }, 400);
|
|
210
|
+
}
|
|
211
|
+
// Call onBeforeDelete hook
|
|
212
|
+
if (accountDeletion?.onBeforeDelete) {
|
|
213
|
+
await accountDeletion.onBeforeDelete(authUserId);
|
|
214
|
+
}
|
|
215
|
+
// Synchronous deletion (default)
|
|
216
|
+
await deleteUserSessions(authUserId);
|
|
217
|
+
await adapter.deleteUser(authUserId);
|
|
218
|
+
if (accountDeletion?.onAfterDelete) {
|
|
219
|
+
await accountDeletion.onAfterDelete(authUserId);
|
|
220
|
+
}
|
|
221
|
+
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
222
|
+
return c.json({ message: "Account deleted" }, 200);
|
|
223
|
+
});
|
|
138
224
|
router.use("/auth/set-password", userAuth);
|
|
139
225
|
router.openapi(withSecurity(createRoute({
|
|
140
226
|
method: "post",
|
|
@@ -173,6 +259,9 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
173
259
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
174
260
|
await AuthService.logout(token);
|
|
175
261
|
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
262
|
+
deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
|
|
263
|
+
if (getCsrfEnabled())
|
|
264
|
+
clearCsrfToken(c);
|
|
176
265
|
return c.json({ message: "Logged out" }, 200);
|
|
177
266
|
});
|
|
178
267
|
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
@@ -190,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
190
279
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
191
280
|
},
|
|
192
281
|
}), async (c) => {
|
|
193
|
-
const ip = c
|
|
282
|
+
const ip = getClientIp(c);
|
|
194
283
|
if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
|
|
195
284
|
return c.json({ error: "Too many verification attempts. Try again later." }, 429);
|
|
196
285
|
}
|
|
@@ -204,37 +293,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
204
293
|
await deleteVerificationToken(token);
|
|
205
294
|
return c.json({ message: "Email verified" }, 200);
|
|
206
295
|
});
|
|
207
|
-
router.
|
|
208
|
-
router.openapi(withSecurity(createRoute({
|
|
296
|
+
router.openapi(createRoute({
|
|
209
297
|
method: "post",
|
|
210
298
|
path: "/auth/resend-verification",
|
|
211
299
|
summary: "Resend verification email",
|
|
212
|
-
description: "
|
|
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.",
|
|
213
301
|
tags,
|
|
302
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
|
|
214
303
|
responses: {
|
|
215
304
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
216
305
|
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
|
|
217
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
218
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this
|
|
306
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
307
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
|
|
219
308
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
220
309
|
},
|
|
221
|
-
}),
|
|
310
|
+
}), async (c) => {
|
|
222
311
|
const adapter = getAuthAdapter();
|
|
223
312
|
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
224
313
|
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
225
314
|
}
|
|
226
|
-
const
|
|
227
|
-
|
|
315
|
+
const body = c.req.valid("json");
|
|
316
|
+
const identifier = body[primaryField];
|
|
317
|
+
if (await trackAttempt(`resend:${identifier}`, resendOpts)) {
|
|
228
318
|
return c.json({ error: "Too many resend attempts. Try again later." }, 429);
|
|
229
319
|
}
|
|
230
|
-
const
|
|
320
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
321
|
+
const user = await findFn(identifier);
|
|
322
|
+
if (!user || !(await Bun.password.verify(body.password, user.passwordHash))) {
|
|
323
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
324
|
+
}
|
|
325
|
+
const alreadyVerified = await adapter.getEmailVerified(user.id);
|
|
231
326
|
if (alreadyVerified)
|
|
232
327
|
return c.json({ error: "Email already verified" }, 400);
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
328
|
+
const fullUser = await adapter.getUser(user.id);
|
|
329
|
+
if (!fullUser?.email)
|
|
235
330
|
return c.json({ error: "No email address on file" }, 400);
|
|
236
|
-
const verificationToken = await createVerificationToken(
|
|
237
|
-
await emailVerification.onSend(
|
|
331
|
+
const verificationToken = await createVerificationToken(user.id, fullUser.email);
|
|
332
|
+
await emailVerification.onSend(fullUser.email, verificationToken);
|
|
238
333
|
return c.json({ message: "Verification email sent" }, 200);
|
|
239
334
|
});
|
|
240
335
|
}
|
|
@@ -253,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
253
348
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
254
349
|
},
|
|
255
350
|
}), async (c) => {
|
|
256
|
-
const ip =
|
|
351
|
+
const ip = getClientIp(c);
|
|
257
352
|
const { email } = c.req.valid("json");
|
|
258
353
|
// Rate-limit by both IP and email to prevent distributed email-bombing
|
|
259
354
|
const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
|
|
@@ -291,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
291
386
|
"application/json": {
|
|
292
387
|
schema: z.object({
|
|
293
388
|
token: z.string().describe("Single-use reset token received via email."),
|
|
294
|
-
password:
|
|
389
|
+
password: resetPasswordSchema().describe("New password."),
|
|
295
390
|
}),
|
|
296
391
|
},
|
|
297
392
|
},
|
|
@@ -305,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
305
400
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
306
401
|
},
|
|
307
402
|
}), async (c) => {
|
|
308
|
-
const ip =
|
|
403
|
+
const ip = getClientIp(c);
|
|
309
404
|
if (await trackAttempt(`reset:${ip}`, resetOpts)) {
|
|
310
405
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
311
406
|
}
|
|
@@ -327,6 +422,54 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
327
422
|
});
|
|
328
423
|
}
|
|
329
424
|
// ---------------------------------------------------------------------------
|
|
425
|
+
// Refresh token route — only mounted when refreshTokens is configured
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
if (refreshTokens) {
|
|
428
|
+
const RefreshResponse = z.object({
|
|
429
|
+
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
|
+
userId: z.string().describe("Unique user ID."),
|
|
432
|
+
}).openapi("RefreshResponse");
|
|
433
|
+
router.openapi(createRoute({
|
|
434
|
+
method: "post",
|
|
435
|
+
path: "/auth/refresh",
|
|
436
|
+
summary: "Refresh access token",
|
|
437
|
+
description: "Exchanges a valid refresh token for a new access token and rotated refresh token. The old refresh token remains valid for a short grace window to handle network drops. If a previously rotated token is reused after the grace window, the entire session is invalidated (token theft detection).",
|
|
438
|
+
tags,
|
|
439
|
+
request: {
|
|
440
|
+
body: {
|
|
441
|
+
content: {
|
|
442
|
+
"application/json": {
|
|
443
|
+
schema: z.object({
|
|
444
|
+
refreshToken: z.string().optional().describe("Refresh token. Can also be sent via the refresh_token cookie or x-refresh-token header."),
|
|
445
|
+
}),
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
description: "Refresh token (optional if sent via cookie or header).",
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
responses: {
|
|
452
|
+
200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
|
|
453
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
|
|
454
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
|
|
455
|
+
},
|
|
456
|
+
}), async (c) => {
|
|
457
|
+
const ip = getClientIp(c);
|
|
458
|
+
if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
|
|
459
|
+
return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
|
|
460
|
+
}
|
|
461
|
+
const body = c.req.valid("json");
|
|
462
|
+
const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
|
|
463
|
+
if (!rt) {
|
|
464
|
+
return c.json({ error: "Refresh token is required" }, 401);
|
|
465
|
+
}
|
|
466
|
+
const result = await AuthService.refresh(rt);
|
|
467
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
|
|
468
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
469
|
+
return c.json(result, 200);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
330
473
|
// Session management
|
|
331
474
|
// ---------------------------------------------------------------------------
|
|
332
475
|
const SessionInfoSchema = z.object({
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createRoute, withSecurity } from "../lib/createRoute";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createRouter } from "../lib/context";
|
|
4
|
+
import { userAuth } from "../middleware/userAuth";
|
|
5
|
+
import { requireRole } from "../middleware/requireRole";
|
|
6
|
+
import { createQueue } from "../lib/queue";
|
|
7
|
+
const tags = ["Jobs"];
|
|
8
|
+
const ErrorResponse = z.object({ error: z.string() });
|
|
9
|
+
const JobStatusResponse = z.object({
|
|
10
|
+
id: z.string().describe("Job ID."),
|
|
11
|
+
state: z.string().describe("Job state: waiting, active, completed, failed, delayed, paused."),
|
|
12
|
+
progress: z.union([z.number(), z.record(z.string(), z.unknown())]).describe("Job progress."),
|
|
13
|
+
result: z.unknown().optional().describe("Job result (when completed)."),
|
|
14
|
+
failedReason: z.string().optional().describe("Failure reason (when failed)."),
|
|
15
|
+
attemptsMade: z.number().describe("Number of attempts made."),
|
|
16
|
+
timestamp: z.number().describe("Unix timestamp (ms) when the job was created."),
|
|
17
|
+
finishedOn: z.number().optional().describe("Unix timestamp (ms) when the job finished."),
|
|
18
|
+
}).openapi("JobStatus");
|
|
19
|
+
export const createJobsRouter = (config) => {
|
|
20
|
+
const router = createRouter();
|
|
21
|
+
const allowedQueues = new Set(config.allowedQueues ?? []);
|
|
22
|
+
const authConfig = config.auth ?? "none";
|
|
23
|
+
const scopeToUser = config.scopeToUser ?? false;
|
|
24
|
+
// Determine if userAuth is involved (for scopeToUser and OpenAPI security schemes)
|
|
25
|
+
const hasUserAuth = authConfig === "userAuth" || Array.isArray(authConfig);
|
|
26
|
+
// Apply middleware based on config
|
|
27
|
+
if (authConfig === "userAuth") {
|
|
28
|
+
router.use("/jobs/*", userAuth);
|
|
29
|
+
if (config.roles?.length) {
|
|
30
|
+
router.use("/jobs/*", requireRole(...config.roles));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else if (Array.isArray(authConfig)) {
|
|
34
|
+
for (const mw of authConfig) {
|
|
35
|
+
router.use("/jobs/*", mw);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// "none" requires no middleware
|
|
39
|
+
function isQueueAllowed(queueName) {
|
|
40
|
+
return allowedQueues.has(queueName);
|
|
41
|
+
}
|
|
42
|
+
/** Determine OpenAPI security for a route */
|
|
43
|
+
function applyRouteSecurity(route) {
|
|
44
|
+
if (authConfig === "userAuth") {
|
|
45
|
+
return withSecurity(route, { cookieAuth: [] }, { userToken: [] });
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(authConfig)) {
|
|
48
|
+
// Custom middleware — mark as cookieAuth/userToken if it likely includes userAuth
|
|
49
|
+
return withSecurity(route, { cookieAuth: [] }, { userToken: [] });
|
|
50
|
+
}
|
|
51
|
+
return route;
|
|
52
|
+
}
|
|
53
|
+
/** Map a BullMQ job to the response shape */
|
|
54
|
+
async function jobToResponse(job) {
|
|
55
|
+
const state = await job.getState();
|
|
56
|
+
return {
|
|
57
|
+
id: job.id,
|
|
58
|
+
state,
|
|
59
|
+
progress: job.progress,
|
|
60
|
+
result: job.returnvalue,
|
|
61
|
+
failedReason: job.failedReason ?? undefined,
|
|
62
|
+
attemptsMade: job.attemptsMade,
|
|
63
|
+
timestamp: job.timestamp,
|
|
64
|
+
finishedOn: job.finishedOn ?? undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ─── List available queues ──────────────────────────────────────────────
|
|
68
|
+
const listQueuesRoute = createRoute({
|
|
69
|
+
method: "get",
|
|
70
|
+
path: "/jobs",
|
|
71
|
+
summary: "List available queues",
|
|
72
|
+
description: "Returns the list of queue names exposed via the API.",
|
|
73
|
+
tags,
|
|
74
|
+
responses: {
|
|
75
|
+
200: {
|
|
76
|
+
content: {
|
|
77
|
+
"application/json": {
|
|
78
|
+
schema: z.object({
|
|
79
|
+
queues: z.array(z.string()).describe("Available queue names."),
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
description: "Available queues.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
router.openapi(applyRouteSecurity(listQueuesRoute), async (c) => {
|
|
88
|
+
return c.json({ queues: [...allowedQueues] }, 200);
|
|
89
|
+
});
|
|
90
|
+
// ─── List jobs in a queue ─────────────────────────────────────────────
|
|
91
|
+
const listJobsRoute = createRoute({
|
|
92
|
+
method: "get",
|
|
93
|
+
path: "/jobs/{queue}",
|
|
94
|
+
summary: "List jobs in a queue",
|
|
95
|
+
description: "Returns a paginated list of jobs in a queue, optionally filtered by state.",
|
|
96
|
+
tags,
|
|
97
|
+
request: {
|
|
98
|
+
params: z.object({
|
|
99
|
+
queue: z.string().describe("Queue name."),
|
|
100
|
+
}),
|
|
101
|
+
query: z.object({
|
|
102
|
+
state: z.enum(["waiting", "active", "completed", "failed", "delayed", "paused"]).optional().describe("Filter by job state."),
|
|
103
|
+
start: z.string().optional().describe("Start index. Default: 0."),
|
|
104
|
+
end: z.string().optional().describe("End index. Default: 19."),
|
|
105
|
+
}),
|
|
106
|
+
},
|
|
107
|
+
responses: {
|
|
108
|
+
200: {
|
|
109
|
+
content: {
|
|
110
|
+
"application/json": {
|
|
111
|
+
schema: z.object({
|
|
112
|
+
jobs: z.array(JobStatusResponse),
|
|
113
|
+
total: z.number().describe("Total jobs matching the filter."),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
description: "Jobs list.",
|
|
118
|
+
},
|
|
119
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
router.openapi(applyRouteSecurity(listJobsRoute), async (c) => {
|
|
123
|
+
const { queue: queueName } = c.req.valid("param");
|
|
124
|
+
if (!isQueueAllowed(queueName)) {
|
|
125
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
126
|
+
}
|
|
127
|
+
const { state, start: startStr, end: endStr } = c.req.valid("query");
|
|
128
|
+
const start = startStr ? parseInt(startStr) : 0;
|
|
129
|
+
const end = endStr ? parseInt(endStr) : 19;
|
|
130
|
+
const queue = createQueue(queueName);
|
|
131
|
+
// Get jobs by state or all jobs
|
|
132
|
+
const stateFilter = state ?? "waiting";
|
|
133
|
+
const jobs = await queue.getJobs([stateFilter], start, end);
|
|
134
|
+
// Get total count for the filtered state
|
|
135
|
+
const counts = await queue.getJobCounts(stateFilter);
|
|
136
|
+
const total = counts[stateFilter] ?? 0;
|
|
137
|
+
// Optionally filter by userId
|
|
138
|
+
let filteredJobs = jobs;
|
|
139
|
+
if (scopeToUser && hasUserAuth) {
|
|
140
|
+
const userId = c.get("authUserId");
|
|
141
|
+
filteredJobs = jobs.filter((job) => job.data?.userId === userId);
|
|
142
|
+
}
|
|
143
|
+
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
144
|
+
return c.json({ jobs: result, total }, 200);
|
|
145
|
+
});
|
|
146
|
+
// ─── Get job status ─────────────────────────────────────────────────────
|
|
147
|
+
const getJobRoute = createRoute({
|
|
148
|
+
method: "get",
|
|
149
|
+
path: "/jobs/{queue}/{id}",
|
|
150
|
+
summary: "Get job status",
|
|
151
|
+
description: "Returns the current state, progress, result, or failure reason for a job.",
|
|
152
|
+
tags,
|
|
153
|
+
request: {
|
|
154
|
+
params: z.object({
|
|
155
|
+
queue: z.string().describe("Queue name."),
|
|
156
|
+
id: z.string().describe("Job ID."),
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
responses: {
|
|
160
|
+
200: { content: { "application/json": { schema: JobStatusResponse } }, description: "Job status." },
|
|
161
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not in allowedQueues." },
|
|
162
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Job not found." },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
router.openapi(applyRouteSecurity(getJobRoute), async (c) => {
|
|
166
|
+
const { queue: queueName, id } = c.req.valid("param");
|
|
167
|
+
if (!isQueueAllowed(queueName)) {
|
|
168
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
169
|
+
}
|
|
170
|
+
const queue = createQueue(queueName);
|
|
171
|
+
const job = await queue.getJob(id);
|
|
172
|
+
if (!job)
|
|
173
|
+
return c.json({ error: "Job not found" }, 404);
|
|
174
|
+
// Scope to user if configured
|
|
175
|
+
if (scopeToUser && hasUserAuth) {
|
|
176
|
+
const userId = c.get("authUserId");
|
|
177
|
+
if (job.data?.userId !== userId) {
|
|
178
|
+
return c.json({ error: "Job not found" }, 404);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return c.json(await jobToResponse(job), 200);
|
|
182
|
+
});
|
|
183
|
+
// ─── Get job logs ───────────────────────────────────────────────────────
|
|
184
|
+
const getJobLogsRoute = createRoute({
|
|
185
|
+
method: "get",
|
|
186
|
+
path: "/jobs/{queue}/{id}/logs",
|
|
187
|
+
summary: "Get job logs",
|
|
188
|
+
description: "Returns logs for a specific job.",
|
|
189
|
+
tags,
|
|
190
|
+
request: {
|
|
191
|
+
params: z.object({
|
|
192
|
+
queue: z.string().describe("Queue name."),
|
|
193
|
+
id: z.string().describe("Job ID."),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
responses: {
|
|
197
|
+
200: {
|
|
198
|
+
content: {
|
|
199
|
+
"application/json": {
|
|
200
|
+
schema: z.object({
|
|
201
|
+
logs: z.array(z.string()).describe("Log entries."),
|
|
202
|
+
count: z.number().describe("Total log count."),
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
description: "Job logs.",
|
|
207
|
+
},
|
|
208
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
209
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Job not found." },
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
router.openapi(applyRouteSecurity(getJobLogsRoute), async (c) => {
|
|
213
|
+
const { queue: queueName, id } = c.req.valid("param");
|
|
214
|
+
if (!isQueueAllowed(queueName)) {
|
|
215
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
216
|
+
}
|
|
217
|
+
const queue = createQueue(queueName);
|
|
218
|
+
const job = await queue.getJob(id);
|
|
219
|
+
if (!job)
|
|
220
|
+
return c.json({ error: "Job not found" }, 404);
|
|
221
|
+
const { logs, count } = await queue.getJobLogs(id);
|
|
222
|
+
return c.json({ logs, count }, 200);
|
|
223
|
+
});
|
|
224
|
+
// ─── Dead letter queue ────────────────────────────────────────────────
|
|
225
|
+
const getDlqRoute = createRoute({
|
|
226
|
+
method: "get",
|
|
227
|
+
path: "/jobs/{queue}/dead-letters",
|
|
228
|
+
summary: "List dead letter queue jobs",
|
|
229
|
+
description: "Returns paginated list of jobs in the dead letter queue for a given source queue.",
|
|
230
|
+
tags,
|
|
231
|
+
request: {
|
|
232
|
+
params: z.object({ queue: z.string().describe("Source queue name (DLQ name is {queue}-dlq).") }),
|
|
233
|
+
query: z.object({
|
|
234
|
+
start: z.string().optional().describe("Start index. Default: 0."),
|
|
235
|
+
end: z.string().optional().describe("End index. Default: 19."),
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
responses: {
|
|
239
|
+
200: {
|
|
240
|
+
content: {
|
|
241
|
+
"application/json": {
|
|
242
|
+
schema: z.object({
|
|
243
|
+
jobs: z.array(JobStatusResponse),
|
|
244
|
+
total: z.number().describe("Total jobs in DLQ."),
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
description: "DLQ jobs.",
|
|
249
|
+
},
|
|
250
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
router.openapi(applyRouteSecurity(getDlqRoute), async (c) => {
|
|
254
|
+
const { queue: queueName } = c.req.valid("param");
|
|
255
|
+
if (!isQueueAllowed(queueName)) {
|
|
256
|
+
return c.json({ error: "Queue not allowed" }, 403);
|
|
257
|
+
}
|
|
258
|
+
const { start: startStr, end: endStr } = c.req.valid("query");
|
|
259
|
+
const start = startStr ? parseInt(startStr) : 0;
|
|
260
|
+
const end = endStr ? parseInt(endStr) : 19;
|
|
261
|
+
const dlqQueue = createQueue(`${queueName}-dlq`);
|
|
262
|
+
const [jobs, total] = await Promise.all([
|
|
263
|
+
dlqQueue.getWaiting(start, end),
|
|
264
|
+
dlqQueue.getWaitingCount(),
|
|
265
|
+
]);
|
|
266
|
+
const result = await Promise.all(jobs.map(jobToResponse));
|
|
267
|
+
return c.json({ jobs: result, total }, 200);
|
|
268
|
+
});
|
|
269
|
+
return router;
|
|
270
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AuthRateLimitConfig } from "../app";
|
|
2
|
+
export interface MfaRouterOptions {
|
|
3
|
+
rateLimit?: AuthRateLimitConfig;
|
|
4
|
+
}
|
|
5
|
+
export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|