@lastshotlabs/bunshot 0.0.9 → 0.0.13
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 +241 -7
- package/dist/adapters/memoryAuth.d.ts +5 -0
- package/dist/adapters/memoryAuth.js +23 -0
- package/dist/adapters/sqliteAuth.d.ts +5 -0
- package/dist/adapters/sqliteAuth.js +18 -0
- package/dist/app.d.ts +48 -2
- package/dist/app.js +72 -5
- package/dist/entrypoints/mongo.d.ts +3 -0
- package/dist/entrypoints/mongo.js +3 -0
- package/dist/entrypoints/queue.d.ts +2 -0
- package/dist/entrypoints/queue.js +1 -0
- package/dist/entrypoints/redis.d.ts +1 -0
- package/dist/entrypoints/redis.js +1 -0
- package/dist/index.d.ts +5 -7
- package/dist/index.js +5 -5
- package/dist/lib/appConfig.d.ts +9 -0
- package/dist/lib/appConfig.js +5 -0
- package/dist/lib/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/lib/emailVerification.js +11 -10
- package/dist/lib/mongo.d.ts +9 -4
- package/dist/lib/mongo.js +61 -10
- package/dist/lib/oauth.js +11 -10
- package/dist/lib/queue.d.ts +3 -4
- package/dist/lib/queue.js +18 -3
- package/dist/lib/redis.d.ts +3 -8
- package/dist/lib/redis.js +19 -8
- package/dist/lib/resetPassword.d.ts +12 -0
- package/dist/lib/resetPassword.js +95 -0
- package/dist/lib/session.js +12 -12
- package/dist/middleware/cacheResponse.js +10 -9
- package/dist/models/AuthUser.d.ts +14 -106
- package/dist/models/AuthUser.js +31 -14
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +176 -59
- package/dist/services/auth.d.ts +8 -1
- package/dist/services/auth.js +5 -3
- package/package.json +38 -8
package/dist/routes/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoute } from "
|
|
1
|
+
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";
|
|
@@ -9,10 +9,17 @@ 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
|
+
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
12
13
|
import { getUserSessions, deleteSession } from "../lib/session";
|
|
13
14
|
const isProd = process.env.NODE_ENV === "production";
|
|
14
|
-
const TokenResponse = z.object({
|
|
15
|
-
|
|
15
|
+
const TokenResponse = z.object({
|
|
16
|
+
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
|
|
17
|
+
userId: z.string().describe("Unique user ID."),
|
|
18
|
+
email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
|
|
19
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
|
|
20
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
|
|
21
|
+
}).openapi("TokenResponse");
|
|
22
|
+
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
16
23
|
const tags = ["Auth"];
|
|
17
24
|
const cookieOptions = {
|
|
18
25
|
httpOnly: true,
|
|
@@ -22,7 +29,7 @@ const cookieOptions = {
|
|
|
22
29
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
23
30
|
};
|
|
24
31
|
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
25
|
-
export const createAuthRouter = ({ primaryField, emailVerification, rateLimit }) => {
|
|
32
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
|
|
26
33
|
const router = createRouter();
|
|
27
34
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
28
35
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -33,16 +40,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
33
40
|
const registerOpts = { windowMs: rateLimit?.register?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.register?.max ?? 5 };
|
|
34
41
|
const verifyOpts = { windowMs: rateLimit?.verifyEmail?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.verifyEmail?.max ?? 10 };
|
|
35
42
|
const resendOpts = { windowMs: rateLimit?.resendVerification?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.resendVerification?.max ?? 3 };
|
|
43
|
+
const forgotOpts = { windowMs: rateLimit?.forgotPassword?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.forgotPassword?.max ?? 5 };
|
|
44
|
+
const resetOpts = { windowMs: rateLimit?.resetPassword?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.resetPassword?.max ?? 10 };
|
|
36
45
|
router.openapi(createRoute({
|
|
37
46
|
method: "post",
|
|
38
47
|
path: "/auth/register",
|
|
48
|
+
summary: "Register a new account",
|
|
49
|
+
description: "Creates a new user account and returns a JWT session token. The token is also set as an HttpOnly session cookie. Rate-limited by IP.",
|
|
39
50
|
tags,
|
|
40
|
-
request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
|
|
51
|
+
request: { body: { content: { "application/json": { schema: RegisterSchema } }, description: "Registration credentials." } },
|
|
41
52
|
responses: {
|
|
42
|
-
201: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
43
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
53
|
+
201: { content: { "application/json": { schema: TokenResponse } }, description: "Account created. Returns a session token." },
|
|
54
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. missing field, password too short)." },
|
|
44
55
|
409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
|
|
45
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
56
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
46
57
|
},
|
|
47
58
|
}), async (c) => {
|
|
48
59
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -55,20 +66,22 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
55
66
|
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
56
67
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
57
68
|
};
|
|
58
|
-
const
|
|
59
|
-
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
60
|
-
return c.json(
|
|
69
|
+
const result = await AuthService.register(identifier, body.password, metadata);
|
|
70
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
71
|
+
return c.json(result, 201);
|
|
61
72
|
});
|
|
62
73
|
router.openapi(createRoute({
|
|
63
74
|
method: "post",
|
|
64
75
|
path: "/auth/login",
|
|
76
|
+
summary: "Log in",
|
|
77
|
+
description: "Authenticates with credentials and returns a JWT session token. The token is also set as an HttpOnly session cookie. Failed attempts are rate-limited per identifier.",
|
|
65
78
|
tags,
|
|
66
|
-
request: { body: { content: { "application/json": { schema: LoginSchema } } } },
|
|
79
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials." } },
|
|
67
80
|
responses: {
|
|
68
|
-
200: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
69
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
|
|
70
|
-
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
|
|
71
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
81
|
+
200: { content: { "application/json": { schema: TokenResponse } }, description: "Authenticated. Returns a session token." },
|
|
82
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
83
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified. Verification is required before login." },
|
|
84
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many failed login attempts for this identifier. Try again later." },
|
|
72
85
|
},
|
|
73
86
|
}), async (c) => {
|
|
74
87
|
const body = c.req.valid("json");
|
|
@@ -93,27 +106,29 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
93
106
|
}
|
|
94
107
|
});
|
|
95
108
|
router.use("/auth/me", userAuth);
|
|
96
|
-
router.openapi(createRoute({
|
|
109
|
+
router.openapi(withSecurity(createRoute({
|
|
97
110
|
method: "get",
|
|
98
111
|
path: "/auth/me",
|
|
112
|
+
summary: "Get current user",
|
|
113
|
+
description: "Returns the authenticated user's profile. Requires a valid session via cookie or x-user-token header.",
|
|
99
114
|
tags,
|
|
100
115
|
responses: {
|
|
101
116
|
200: {
|
|
102
117
|
content: {
|
|
103
118
|
"application/json": {
|
|
104
119
|
schema: z.object({
|
|
105
|
-
userId: z.string(),
|
|
106
|
-
email: z.string().optional(),
|
|
107
|
-
emailVerified: z.boolean().optional(),
|
|
108
|
-
googleLinked: z.boolean().optional(),
|
|
120
|
+
userId: z.string().describe("Unique user ID."),
|
|
121
|
+
email: z.string().optional().describe("User's email address."),
|
|
122
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified."),
|
|
123
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked."),
|
|
109
124
|
}),
|
|
110
125
|
},
|
|
111
126
|
},
|
|
112
|
-
description: "
|
|
127
|
+
description: "Authenticated user's profile.",
|
|
113
128
|
},
|
|
114
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
129
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
115
130
|
},
|
|
116
|
-
}), async (c) => {
|
|
131
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
117
132
|
const authUserId = c.get("authUserId");
|
|
118
133
|
const adapter = getAuthAdapter();
|
|
119
134
|
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
@@ -121,17 +136,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
121
136
|
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
122
137
|
});
|
|
123
138
|
router.use("/auth/set-password", userAuth);
|
|
124
|
-
router.openapi(createRoute({
|
|
139
|
+
router.openapi(withSecurity(createRoute({
|
|
125
140
|
method: "post",
|
|
126
141
|
path: "/auth/set-password",
|
|
142
|
+
summary: "Set or update password",
|
|
143
|
+
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.",
|
|
127
144
|
tags,
|
|
128
|
-
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
|
|
145
|
+
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8).describe("New password. Minimum 8 characters.") }) } }, description: "New password." } },
|
|
129
146
|
responses: {
|
|
130
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password
|
|
131
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
132
|
-
|
|
147
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
|
|
148
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
|
|
149
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
150
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
133
151
|
},
|
|
134
|
-
}), async (c) => {
|
|
152
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
135
153
|
const adapter = getAuthAdapter();
|
|
136
154
|
if (!adapter.setPassword) {
|
|
137
155
|
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
@@ -145,9 +163,11 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
145
163
|
router.openapi(createRoute({
|
|
146
164
|
method: "post",
|
|
147
165
|
path: "/auth/logout",
|
|
166
|
+
summary: "Log out",
|
|
167
|
+
description: "Invalidates the current session and clears the session cookie. Safe to call even without an active session.",
|
|
148
168
|
tags,
|
|
149
169
|
responses: {
|
|
150
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
|
|
170
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out. Session is invalidated and cookie is cleared." },
|
|
151
171
|
},
|
|
152
172
|
}), async (c) => {
|
|
153
173
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
@@ -160,12 +180,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
160
180
|
router.openapi(createRoute({
|
|
161
181
|
method: "post",
|
|
162
182
|
path: "/auth/verify-email",
|
|
183
|
+
summary: "Verify email address",
|
|
184
|
+
description: "Consumes a single-use email verification token and marks the account as verified. The token is delivered by the `emailVerification.onSend` callback configured in CreateAppConfig. Rate-limited by IP.",
|
|
163
185
|
tags,
|
|
164
|
-
request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
|
|
186
|
+
request: { body: { content: { "application/json": { schema: z.object({ token: z.string().describe("Single-use verification token received via email.") }) } }, description: "Verification token." } },
|
|
165
187
|
responses: {
|
|
166
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
|
|
167
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
|
|
168
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
188
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified successfully." },
|
|
189
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired verification token." },
|
|
190
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
169
191
|
},
|
|
170
192
|
}), async (c) => {
|
|
171
193
|
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
@@ -183,17 +205,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
183
205
|
return c.json({ message: "Email verified" }, 200);
|
|
184
206
|
});
|
|
185
207
|
router.use("/auth/resend-verification", userAuth);
|
|
186
|
-
router.openapi(createRoute({
|
|
208
|
+
router.openapi(withSecurity(createRoute({
|
|
187
209
|
method: "post",
|
|
188
210
|
path: "/auth/resend-verification",
|
|
211
|
+
summary: "Resend verification email",
|
|
212
|
+
description: "Sends a new verification email to the authenticated user's address. Returns 400 if already verified. Rate-limited per user. Requires a valid session.",
|
|
189
213
|
tags,
|
|
190
214
|
responses: {
|
|
191
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
|
|
192
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
216
|
+
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: "No valid session." },
|
|
218
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this user. Try again later." },
|
|
219
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
195
220
|
},
|
|
196
|
-
}), async (c) => {
|
|
221
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
197
222
|
const adapter = getAuthAdapter();
|
|
198
223
|
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
199
224
|
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
@@ -213,47 +238,139 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
213
238
|
return c.json({ message: "Verification email sent" }, 200);
|
|
214
239
|
});
|
|
215
240
|
}
|
|
241
|
+
// Password reset routes — only mounted when passwordReset is configured and primaryField is "email"
|
|
242
|
+
if (passwordReset && primaryField === "email") {
|
|
243
|
+
router.openapi(createRoute({
|
|
244
|
+
method: "post",
|
|
245
|
+
path: "/auth/forgot-password",
|
|
246
|
+
summary: "Request password reset",
|
|
247
|
+
description: "Sends a password reset email if the address is registered. Always returns 200 regardless of whether the address exists, to prevent email enumeration. Rate-limited by both IP and email address.",
|
|
248
|
+
tags,
|
|
249
|
+
request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email().describe("Email address to send the reset link to.") }) } }, description: "Email address for the account to reset." } },
|
|
250
|
+
responses: {
|
|
251
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Request received. A reset email will be sent if the address is registered." },
|
|
252
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. not a valid email address)." },
|
|
253
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
254
|
+
},
|
|
255
|
+
}), async (c) => {
|
|
256
|
+
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
257
|
+
const { email } = c.req.valid("json");
|
|
258
|
+
// Rate-limit by both IP and email to prevent distributed email-bombing
|
|
259
|
+
const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
|
|
260
|
+
const emailLimited = await trackAttempt(`forgot:email:${email}`, forgotOpts);
|
|
261
|
+
if (ipLimited || emailLimited) {
|
|
262
|
+
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
263
|
+
}
|
|
264
|
+
const adapter = getAuthAdapter();
|
|
265
|
+
const user = await adapter.findByEmail(email);
|
|
266
|
+
// Fire-and-forget: the response does not wait for token creation or email sending,
|
|
267
|
+
// which reduces obvious timing differences between registered and unregistered emails.
|
|
268
|
+
const msg = { message: "If that email is registered, a password reset link has been sent." };
|
|
269
|
+
if (user) {
|
|
270
|
+
void (async () => {
|
|
271
|
+
try {
|
|
272
|
+
const token = await createResetToken(user.id, email);
|
|
273
|
+
await passwordReset.onSend(email, token);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
console.error("Failed to send password reset email:", err);
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
}
|
|
280
|
+
return c.json(msg, 200);
|
|
281
|
+
});
|
|
282
|
+
router.openapi(createRoute({
|
|
283
|
+
method: "post",
|
|
284
|
+
path: "/auth/reset-password",
|
|
285
|
+
summary: "Reset password",
|
|
286
|
+
description: "Consumes a single-use reset token and sets a new password. All active sessions are revoked after a successful reset to invalidate any stolen JWTs. Rate-limited by IP.",
|
|
287
|
+
tags,
|
|
288
|
+
request: {
|
|
289
|
+
body: {
|
|
290
|
+
content: {
|
|
291
|
+
"application/json": {
|
|
292
|
+
schema: z.object({
|
|
293
|
+
token: z.string().describe("Single-use reset token received via email."),
|
|
294
|
+
password: z.string().min(8).describe("New password. Minimum 8 characters."),
|
|
295
|
+
}),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
description: "Reset token and new password.",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
responses: {
|
|
302
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset. All sessions have been revoked." },
|
|
303
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or the reset token is invalid or expired." },
|
|
304
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many reset attempts from this IP. Try again later." },
|
|
305
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
306
|
+
},
|
|
307
|
+
}), async (c) => {
|
|
308
|
+
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
309
|
+
if (await trackAttempt(`reset:${ip}`, resetOpts)) {
|
|
310
|
+
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
311
|
+
}
|
|
312
|
+
const { token, password } = c.req.valid("json");
|
|
313
|
+
// consumeResetToken atomically gets and deletes — prevents concurrent replay
|
|
314
|
+
const entry = await consumeResetToken(token);
|
|
315
|
+
if (!entry)
|
|
316
|
+
return c.json({ error: "Invalid or expired reset token" }, 400);
|
|
317
|
+
const adapter = getAuthAdapter();
|
|
318
|
+
if (!adapter.setPassword) {
|
|
319
|
+
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
320
|
+
}
|
|
321
|
+
const passwordHash = await Bun.password.hash(password);
|
|
322
|
+
await adapter.setPassword(entry.userId, passwordHash);
|
|
323
|
+
// Revoke all sessions so stolen JWTs can't stay valid after a reset
|
|
324
|
+
const sessions = await getUserSessions(entry.userId);
|
|
325
|
+
await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
|
|
326
|
+
return c.json({ message: "Password reset successfully" }, 200);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
216
329
|
// ---------------------------------------------------------------------------
|
|
217
330
|
// Session management
|
|
218
331
|
// ---------------------------------------------------------------------------
|
|
219
332
|
const SessionInfoSchema = z.object({
|
|
220
|
-
sessionId: z.string(),
|
|
221
|
-
createdAt: z.number(),
|
|
222
|
-
lastActiveAt: z.number(),
|
|
223
|
-
expiresAt: z.number(),
|
|
224
|
-
ipAddress: z.string().optional(),
|
|
225
|
-
userAgent: z.string().optional(),
|
|
226
|
-
isActive: z.boolean(),
|
|
227
|
-
});
|
|
333
|
+
sessionId: z.string().describe("Unique session identifier (UUID)."),
|
|
334
|
+
createdAt: z.number().describe("Unix timestamp (ms) when the session was created."),
|
|
335
|
+
lastActiveAt: z.number().describe("Unix timestamp (ms) of the most recent authenticated request (updated when trackLastActive is enabled)."),
|
|
336
|
+
expiresAt: z.number().describe("Unix timestamp (ms) when the session expires."),
|
|
337
|
+
ipAddress: z.string().optional().describe("IP address of the client at session creation."),
|
|
338
|
+
userAgent: z.string().optional().describe("User-agent string of the client at session creation."),
|
|
339
|
+
isActive: z.boolean().describe("Whether the session is currently valid and unexpired."),
|
|
340
|
+
}).openapi("SessionInfo");
|
|
228
341
|
router.use("/auth/sessions", userAuth);
|
|
229
342
|
router.use("/auth/sessions/*", userAuth);
|
|
230
|
-
router.openapi(createRoute({
|
|
343
|
+
router.openapi(withSecurity(createRoute({
|
|
231
344
|
method: "get",
|
|
232
345
|
path: "/auth/sessions",
|
|
346
|
+
summary: "List sessions",
|
|
347
|
+
description: "Returns all sessions for the authenticated user. Includes inactive sessions when `sessionPolicy.includeInactiveSessions` is enabled. Requires a valid session.",
|
|
233
348
|
tags,
|
|
234
349
|
responses: {
|
|
235
350
|
200: {
|
|
236
351
|
content: { "application/json": { schema: z.object({ sessions: z.array(SessionInfoSchema) }) } },
|
|
237
|
-
description: "
|
|
352
|
+
description: "Sessions belonging to the authenticated user.",
|
|
238
353
|
},
|
|
239
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
354
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
240
355
|
},
|
|
241
|
-
}), async (c) => {
|
|
356
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
242
357
|
const userId = c.get("authUserId");
|
|
243
358
|
const sessions = await getUserSessions(userId);
|
|
244
359
|
return c.json({ sessions }, 200);
|
|
245
360
|
});
|
|
246
|
-
router.openapi(createRoute({
|
|
361
|
+
router.openapi(withSecurity(createRoute({
|
|
247
362
|
method: "delete",
|
|
248
363
|
path: "/auth/sessions/{sessionId}",
|
|
364
|
+
summary: "Revoke a session",
|
|
365
|
+
description: "Revokes a specific session by ID. Users can only revoke their own sessions. Useful for 'sign out of other devices' flows. Requires a valid session.",
|
|
249
366
|
tags,
|
|
250
|
-
request: { params: z.object({ sessionId: z.string() }) },
|
|
367
|
+
request: { params: z.object({ sessionId: z.string().describe("UUID of the session to revoke.") }) },
|
|
251
368
|
responses: {
|
|
252
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked" },
|
|
253
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
254
|
-
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found" },
|
|
369
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked successfully." },
|
|
370
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
371
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found or does not belong to the authenticated user." },
|
|
255
372
|
},
|
|
256
|
-
}), async (c) => {
|
|
373
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
257
374
|
const userId = c.get("authUserId");
|
|
258
375
|
const { sessionId } = c.req.valid("param");
|
|
259
376
|
const sessions = await getUserSessions(userId);
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { SessionMetadata } from "../lib/session";
|
|
2
|
-
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<
|
|
2
|
+
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
|
|
3
|
+
token: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
}>;
|
|
3
7
|
export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
|
|
4
8
|
token: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
email?: string;
|
|
5
11
|
emailVerified?: boolean;
|
|
12
|
+
googleLinked?: boolean;
|
|
6
13
|
}>;
|
|
7
14
|
export declare const logout: (token: string | null) => Promise<void>;
|
package/dist/services/auth.js
CHANGED
|
@@ -27,7 +27,7 @@ export const register = async (identifier, password, metadata) => {
|
|
|
27
27
|
console.error("[email-verification] Failed to send verification email:", e);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
return token;
|
|
30
|
+
return { token, userId: user.id, email: identifier };
|
|
31
31
|
};
|
|
32
32
|
export const login = async (identifier, password, metadata) => {
|
|
33
33
|
const adapter = getAuthAdapter();
|
|
@@ -41,6 +41,8 @@ export const login = async (identifier, password, metadata) => {
|
|
|
41
41
|
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
42
42
|
await evictOldestSession(user.id);
|
|
43
43
|
}
|
|
44
|
+
const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
|
|
45
|
+
const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
44
46
|
const evConfig = getEmailVerificationConfig();
|
|
45
47
|
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
46
48
|
const verified = await adapter.getEmailVerified(user.id);
|
|
@@ -48,10 +50,10 @@ export const login = async (identifier, password, metadata) => {
|
|
|
48
50
|
throw new HttpError(403, "Email not verified");
|
|
49
51
|
}
|
|
50
52
|
await createSession(user.id, token, sessionId, metadata);
|
|
51
|
-
return { token, emailVerified: verified };
|
|
53
|
+
return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked };
|
|
52
54
|
}
|
|
53
55
|
await createSession(user.id, token, sessionId, metadata);
|
|
54
|
-
return { token };
|
|
56
|
+
return { token, userId: user.id, email: fullUser?.email, googleLinked };
|
|
55
57
|
};
|
|
56
58
|
export const logout = async (token) => {
|
|
57
59
|
if (token) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lastshotlabs/bunshot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
".": {
|
|
23
23
|
"import": "./dist/index.js",
|
|
24
24
|
"types": "./dist/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./mongo": {
|
|
27
|
+
"import": "./dist/entrypoints/mongo.js",
|
|
28
|
+
"types": "./dist/entrypoints/mongo.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./redis": {
|
|
31
|
+
"import": "./dist/entrypoints/redis.js",
|
|
32
|
+
"types": "./dist/entrypoints/redis.d.ts"
|
|
33
|
+
},
|
|
34
|
+
"./queue": {
|
|
35
|
+
"import": "./dist/entrypoints/queue.js",
|
|
36
|
+
"types": "./dist/entrypoints/queue.d.ts"
|
|
25
37
|
}
|
|
26
38
|
},
|
|
27
39
|
"bin": {
|
|
@@ -41,17 +53,35 @@
|
|
|
41
53
|
"@hono/zod-openapi": "1.2.2",
|
|
42
54
|
"@scalar/hono-api-reference": "0.10.0",
|
|
43
55
|
"arctic": "^3.7.0",
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
56
|
+
"jose": "6.2.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"hono": ">=4.12 <5",
|
|
60
|
+
"zod": ">=4.0 <5",
|
|
61
|
+
"mongoose": ">=9.0 <10",
|
|
62
|
+
"ioredis": ">=5.0 <6",
|
|
63
|
+
"bullmq": ">=5.0 <6"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"mongoose": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"ioredis": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"bullmq": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
50
75
|
},
|
|
51
76
|
"devDependencies": {
|
|
52
77
|
"@types/bun": "1.3.10",
|
|
53
78
|
"tsc-alias": "^1.8.16",
|
|
54
|
-
"typescript": "^5.9.3"
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"hono": ">=4.12",
|
|
81
|
+
"zod": ">=4.0",
|
|
82
|
+
"mongoose": "9.2.4",
|
|
83
|
+
"ioredis": "5.10.0",
|
|
84
|
+
"bullmq": "^5.70.4"
|
|
55
85
|
},
|
|
56
86
|
"publishConfig": {
|
|
57
87
|
"access": "public"
|