@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.
- package/README.md +2510 -1580
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +107 -2
- package/dist/app.js +83 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +15 -5
- package/dist/index.js +10 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -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/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- 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 +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- 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 +8 -0
- package/dist/models/AuthUser.js +8 -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 +253 -80
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +21 -2
- package/dist/services/auth.js +97 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- 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 +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -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 +83 -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 +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -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 +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -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 +100 -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 +19 -10
package/dist/routes/auth.js
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
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";
|
|
5
5
|
import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
|
|
6
|
-
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
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 } from "../lib/appConfig";
|
|
14
|
+
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
14
15
|
const isProd = process.env.NODE_ENV === "production";
|
|
15
|
-
const TokenResponse = z.object({
|
|
16
|
-
|
|
16
|
+
const TokenResponse = z.object({
|
|
17
|
+
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
18
|
+
userId: z.string().describe("Unique user ID."),
|
|
19
|
+
email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
|
|
20
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
|
|
21
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
|
|
22
|
+
refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
|
|
23
|
+
mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
|
|
24
|
+
mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
|
|
25
|
+
mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
|
|
26
|
+
}).openapi("TokenResponse");
|
|
27
|
+
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
17
28
|
const tags = ["Auth"];
|
|
18
|
-
const cookieOptions = {
|
|
29
|
+
const cookieOptions = (maxAge) => ({
|
|
19
30
|
httpOnly: true,
|
|
20
31
|
secure: isProd,
|
|
21
32
|
sameSite: "Lax",
|
|
22
33
|
path: "/",
|
|
23
|
-
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
24
|
-
};
|
|
34
|
+
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
35
|
+
});
|
|
25
36
|
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
26
|
-
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
|
|
37
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
27
38
|
const router = createRouter();
|
|
28
39
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
29
40
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -39,13 +50,15 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
39
50
|
router.openapi(createRoute({
|
|
40
51
|
method: "post",
|
|
41
52
|
path: "/auth/register",
|
|
53
|
+
summary: "Register a new account",
|
|
54
|
+
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.",
|
|
42
55
|
tags,
|
|
43
|
-
request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
|
|
56
|
+
request: { body: { content: { "application/json": { schema: RegisterSchema } }, description: "Registration credentials." } },
|
|
44
57
|
responses: {
|
|
45
|
-
201: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
46
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
58
|
+
201: { content: { "application/json": { schema: TokenResponse } }, description: "Account created. Returns a session token." },
|
|
59
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. missing field, password too short)." },
|
|
47
60
|
409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
|
|
48
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
61
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
49
62
|
},
|
|
50
63
|
}), async (c) => {
|
|
51
64
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -58,20 +71,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
58
71
|
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
59
72
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
60
73
|
};
|
|
61
|
-
const
|
|
62
|
-
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
63
|
-
|
|
74
|
+
const result = await AuthService.register(identifier, body.password, metadata);
|
|
75
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
76
|
+
if (result.refreshToken) {
|
|
77
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
78
|
+
}
|
|
79
|
+
return c.json(result, 201);
|
|
64
80
|
});
|
|
65
81
|
router.openapi(createRoute({
|
|
66
82
|
method: "post",
|
|
67
83
|
path: "/auth/login",
|
|
84
|
+
summary: "Log in",
|
|
85
|
+
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.",
|
|
68
86
|
tags,
|
|
69
|
-
request: { body: { content: { "application/json": { schema: LoginSchema } } } },
|
|
87
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials." } },
|
|
70
88
|
responses: {
|
|
71
|
-
200: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
72
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
|
|
73
|
-
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
|
|
74
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
89
|
+
200: { content: { "application/json": { schema: TokenResponse } }, description: "Authenticated. Returns a session token." },
|
|
90
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
91
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified. Verification is required before login." },
|
|
92
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many failed login attempts for this identifier. Try again later." },
|
|
75
93
|
},
|
|
76
94
|
}), async (c) => {
|
|
77
95
|
const body = c.req.valid("json");
|
|
@@ -87,7 +105,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
87
105
|
try {
|
|
88
106
|
const result = await AuthService.login(identifier, body.password, metadata);
|
|
89
107
|
await bustAuthLimit(limitKey); // success — clear failure count
|
|
90
|
-
|
|
108
|
+
if (!result.mfaRequired) {
|
|
109
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
110
|
+
if (result.refreshToken) {
|
|
111
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
91
114
|
return c.json(result, 200);
|
|
92
115
|
}
|
|
93
116
|
catch (err) {
|
|
@@ -96,45 +119,118 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
96
119
|
}
|
|
97
120
|
});
|
|
98
121
|
router.use("/auth/me", userAuth);
|
|
99
|
-
router.openapi(createRoute({
|
|
122
|
+
router.openapi(withSecurity(createRoute({
|
|
100
123
|
method: "get",
|
|
101
124
|
path: "/auth/me",
|
|
125
|
+
summary: "Get current user",
|
|
126
|
+
description: "Returns the authenticated user's profile. Requires a valid session via cookie or x-user-token header.",
|
|
102
127
|
tags,
|
|
103
128
|
responses: {
|
|
104
129
|
200: {
|
|
105
130
|
content: {
|
|
106
131
|
"application/json": {
|
|
107
132
|
schema: z.object({
|
|
108
|
-
userId: z.string(),
|
|
109
|
-
email: z.string().optional(),
|
|
110
|
-
emailVerified: z.boolean().optional(),
|
|
111
|
-
googleLinked: z.boolean().optional(),
|
|
133
|
+
userId: z.string().describe("Unique user ID."),
|
|
134
|
+
email: z.string().optional().describe("User's email address."),
|
|
135
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified."),
|
|
136
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked."),
|
|
112
137
|
}),
|
|
113
138
|
},
|
|
114
139
|
},
|
|
115
|
-
description: "
|
|
140
|
+
description: "Authenticated user's profile.",
|
|
116
141
|
},
|
|
117
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
142
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
118
143
|
},
|
|
119
|
-
}), async (c) => {
|
|
144
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
120
145
|
const authUserId = c.get("authUserId");
|
|
121
146
|
const adapter = getAuthAdapter();
|
|
122
147
|
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
123
148
|
const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
124
149
|
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
125
150
|
});
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Account deletion
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
const deleteAccountOpts = { windowMs: rateLimit?.deleteAccount?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.deleteAccount?.max ?? 3 };
|
|
155
|
+
router.openapi(withSecurity(createRoute({
|
|
156
|
+
method: "delete",
|
|
157
|
+
path: "/auth/me",
|
|
158
|
+
summary: "Delete account",
|
|
159
|
+
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.",
|
|
160
|
+
tags,
|
|
161
|
+
request: {
|
|
162
|
+
body: {
|
|
163
|
+
content: {
|
|
164
|
+
"application/json": {
|
|
165
|
+
schema: z.object({
|
|
166
|
+
password: z.string().optional().describe("Current password. Required for credential accounts, optional for OAuth-only accounts."),
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
description: "Password confirmation.",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
responses: {
|
|
174
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deleted." },
|
|
175
|
+
202: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion has been scheduled." },
|
|
176
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Password is required for credential accounts." },
|
|
177
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid password or no valid session." },
|
|
178
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many deletion attempts. Try again later." },
|
|
179
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support deleteUser." },
|
|
180
|
+
},
|
|
181
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
182
|
+
const authUserId = c.get("authUserId");
|
|
183
|
+
if (await trackAttempt(`deleteaccount:${authUserId}`, deleteAccountOpts)) {
|
|
184
|
+
return c.json({ error: "Too many deletion attempts. Try again later." }, 429);
|
|
185
|
+
}
|
|
186
|
+
const adapter = getAuthAdapter();
|
|
187
|
+
if (!adapter.deleteUser) {
|
|
188
|
+
return c.json({ error: "Auth adapter does not support deleteUser" }, 501);
|
|
189
|
+
}
|
|
190
|
+
const { password } = c.req.valid("json");
|
|
191
|
+
// Verify password for credential accounts
|
|
192
|
+
if (password) {
|
|
193
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
194
|
+
const email = user?.email;
|
|
195
|
+
if (email) {
|
|
196
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
197
|
+
const found = await findFn(email);
|
|
198
|
+
if (found && !(await Bun.password.verify(password, found.passwordHash))) {
|
|
199
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (adapter.hasPassword && await adapter.hasPassword(authUserId)) {
|
|
204
|
+
return c.json({ error: "Password is required to delete a credential account" }, 400);
|
|
205
|
+
}
|
|
206
|
+
// Call onBeforeDelete hook
|
|
207
|
+
if (accountDeletion?.onBeforeDelete) {
|
|
208
|
+
await accountDeletion.onBeforeDelete(authUserId);
|
|
209
|
+
}
|
|
210
|
+
// Synchronous deletion (default)
|
|
211
|
+
await deleteUserSessions(authUserId);
|
|
212
|
+
await adapter.deleteUser(authUserId);
|
|
213
|
+
if (accountDeletion?.onAfterDelete) {
|
|
214
|
+
await accountDeletion.onAfterDelete(authUserId);
|
|
215
|
+
}
|
|
216
|
+
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
217
|
+
return c.json({ message: "Account deleted" }, 200);
|
|
218
|
+
});
|
|
126
219
|
router.use("/auth/set-password", userAuth);
|
|
127
|
-
router.openapi(createRoute({
|
|
220
|
+
router.openapi(withSecurity(createRoute({
|
|
128
221
|
method: "post",
|
|
129
222
|
path: "/auth/set-password",
|
|
223
|
+
summary: "Set or update password",
|
|
224
|
+
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.",
|
|
130
225
|
tags,
|
|
131
|
-
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
|
|
226
|
+
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8).describe("New password. Minimum 8 characters.") }) } }, description: "New password." } },
|
|
132
227
|
responses: {
|
|
133
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password
|
|
134
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
135
|
-
|
|
228
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
|
|
229
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
|
|
230
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
231
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
136
232
|
},
|
|
137
|
-
}), async (c) => {
|
|
233
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
138
234
|
const adapter = getAuthAdapter();
|
|
139
235
|
if (!adapter.setPassword) {
|
|
140
236
|
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
@@ -148,14 +244,17 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
148
244
|
router.openapi(createRoute({
|
|
149
245
|
method: "post",
|
|
150
246
|
path: "/auth/logout",
|
|
247
|
+
summary: "Log out",
|
|
248
|
+
description: "Invalidates the current session and clears the session cookie. Safe to call even without an active session.",
|
|
151
249
|
tags,
|
|
152
250
|
responses: {
|
|
153
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
|
|
251
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out. Session is invalidated and cookie is cleared." },
|
|
154
252
|
},
|
|
155
253
|
}), async (c) => {
|
|
156
254
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
157
255
|
await AuthService.logout(token);
|
|
158
256
|
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
257
|
+
deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
|
|
159
258
|
return c.json({ message: "Logged out" }, 200);
|
|
160
259
|
});
|
|
161
260
|
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
@@ -163,12 +262,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
163
262
|
router.openapi(createRoute({
|
|
164
263
|
method: "post",
|
|
165
264
|
path: "/auth/verify-email",
|
|
265
|
+
summary: "Verify email address",
|
|
266
|
+
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.",
|
|
166
267
|
tags,
|
|
167
|
-
request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
|
|
268
|
+
request: { body: { content: { "application/json": { schema: z.object({ token: z.string().describe("Single-use verification token received via email.") }) } }, description: "Verification token." } },
|
|
168
269
|
responses: {
|
|
169
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
|
|
170
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
|
|
171
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
270
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified successfully." },
|
|
271
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired verification token." },
|
|
272
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
172
273
|
},
|
|
173
274
|
}), async (c) => {
|
|
174
275
|
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
@@ -185,34 +286,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
185
286
|
await deleteVerificationToken(token);
|
|
186
287
|
return c.json({ message: "Email verified" }, 200);
|
|
187
288
|
});
|
|
188
|
-
router.use("/auth/resend-verification", userAuth);
|
|
189
289
|
router.openapi(createRoute({
|
|
190
290
|
method: "post",
|
|
191
291
|
path: "/auth/resend-verification",
|
|
292
|
+
summary: "Resend verification email",
|
|
293
|
+
description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
|
|
192
294
|
tags,
|
|
295
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
|
|
193
296
|
responses: {
|
|
194
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
|
|
195
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
196
|
-
|
|
197
|
-
|
|
297
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
298
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
|
|
299
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
300
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
|
|
301
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
198
302
|
},
|
|
199
303
|
}), async (c) => {
|
|
200
304
|
const adapter = getAuthAdapter();
|
|
201
305
|
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
202
306
|
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
203
307
|
}
|
|
204
|
-
const
|
|
205
|
-
|
|
308
|
+
const body = c.req.valid("json");
|
|
309
|
+
const identifier = body[primaryField];
|
|
310
|
+
if (await trackAttempt(`resend:${identifier}`, resendOpts)) {
|
|
206
311
|
return c.json({ error: "Too many resend attempts. Try again later." }, 429);
|
|
207
312
|
}
|
|
208
|
-
const
|
|
313
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
314
|
+
const user = await findFn(identifier);
|
|
315
|
+
if (!user || !(await Bun.password.verify(body.password, user.passwordHash))) {
|
|
316
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
317
|
+
}
|
|
318
|
+
const alreadyVerified = await adapter.getEmailVerified(user.id);
|
|
209
319
|
if (alreadyVerified)
|
|
210
320
|
return c.json({ error: "Email already verified" }, 400);
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
321
|
+
const fullUser = await adapter.getUser(user.id);
|
|
322
|
+
if (!fullUser?.email)
|
|
213
323
|
return c.json({ error: "No email address on file" }, 400);
|
|
214
|
-
const verificationToken = await createVerificationToken(
|
|
215
|
-
await emailVerification.onSend(
|
|
324
|
+
const verificationToken = await createVerificationToken(user.id, fullUser.email);
|
|
325
|
+
await emailVerification.onSend(fullUser.email, verificationToken);
|
|
216
326
|
return c.json({ message: "Verification email sent" }, 200);
|
|
217
327
|
});
|
|
218
328
|
}
|
|
@@ -221,12 +331,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
221
331
|
router.openapi(createRoute({
|
|
222
332
|
method: "post",
|
|
223
333
|
path: "/auth/forgot-password",
|
|
334
|
+
summary: "Request password reset",
|
|
335
|
+
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.",
|
|
224
336
|
tags,
|
|
225
|
-
request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email() }) } } } },
|
|
337
|
+
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." } },
|
|
226
338
|
responses: {
|
|
227
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "
|
|
228
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
229
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
339
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Request received. A reset email will be sent if the address is registered." },
|
|
340
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. not a valid email address)." },
|
|
341
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
230
342
|
},
|
|
231
343
|
}), async (c) => {
|
|
232
344
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -258,13 +370,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
258
370
|
router.openapi(createRoute({
|
|
259
371
|
method: "post",
|
|
260
372
|
path: "/auth/reset-password",
|
|
373
|
+
summary: "Reset password",
|
|
374
|
+
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.",
|
|
261
375
|
tags,
|
|
262
|
-
request: {
|
|
376
|
+
request: {
|
|
377
|
+
body: {
|
|
378
|
+
content: {
|
|
379
|
+
"application/json": {
|
|
380
|
+
schema: z.object({
|
|
381
|
+
token: z.string().describe("Single-use reset token received via email."),
|
|
382
|
+
password: z.string().min(8).describe("New password. Minimum 8 characters."),
|
|
383
|
+
}),
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
description: "Reset token and new password.",
|
|
387
|
+
},
|
|
388
|
+
},
|
|
263
389
|
responses: {
|
|
264
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset
|
|
265
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error or invalid
|
|
266
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
267
|
-
501: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
390
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset. All sessions have been revoked." },
|
|
391
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or the reset token is invalid or expired." },
|
|
392
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many reset attempts from this IP. Try again later." },
|
|
393
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
268
394
|
},
|
|
269
395
|
}), async (c) => {
|
|
270
396
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -289,46 +415,93 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
289
415
|
});
|
|
290
416
|
}
|
|
291
417
|
// ---------------------------------------------------------------------------
|
|
418
|
+
// Refresh token route — only mounted when refreshTokens is configured
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
if (refreshTokens) {
|
|
421
|
+
const RefreshResponse = z.object({
|
|
422
|
+
token: z.string().describe("New short-lived JWT access token."),
|
|
423
|
+
refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
|
|
424
|
+
userId: z.string().describe("Unique user ID."),
|
|
425
|
+
}).openapi("RefreshResponse");
|
|
426
|
+
router.openapi(createRoute({
|
|
427
|
+
method: "post",
|
|
428
|
+
path: "/auth/refresh",
|
|
429
|
+
summary: "Refresh access token",
|
|
430
|
+
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).",
|
|
431
|
+
tags,
|
|
432
|
+
request: {
|
|
433
|
+
body: {
|
|
434
|
+
content: {
|
|
435
|
+
"application/json": {
|
|
436
|
+
schema: z.object({
|
|
437
|
+
refreshToken: z.string().optional().describe("Refresh token. Can also be sent via the refresh_token cookie or x-refresh-token header."),
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
description: "Refresh token (optional if sent via cookie or header).",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
responses: {
|
|
445
|
+
200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
|
|
446
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
|
|
447
|
+
},
|
|
448
|
+
}), async (c) => {
|
|
449
|
+
const body = c.req.valid("json");
|
|
450
|
+
const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
|
|
451
|
+
if (!rt) {
|
|
452
|
+
return c.json({ error: "Refresh token is required" }, 401);
|
|
453
|
+
}
|
|
454
|
+
const result = await AuthService.refresh(rt);
|
|
455
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
|
|
456
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
457
|
+
return c.json(result, 200);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
292
461
|
// Session management
|
|
293
462
|
// ---------------------------------------------------------------------------
|
|
294
463
|
const SessionInfoSchema = z.object({
|
|
295
|
-
sessionId: z.string(),
|
|
296
|
-
createdAt: z.number(),
|
|
297
|
-
lastActiveAt: z.number(),
|
|
298
|
-
expiresAt: z.number(),
|
|
299
|
-
ipAddress: z.string().optional(),
|
|
300
|
-
userAgent: z.string().optional(),
|
|
301
|
-
isActive: z.boolean(),
|
|
302
|
-
});
|
|
464
|
+
sessionId: z.string().describe("Unique session identifier (UUID)."),
|
|
465
|
+
createdAt: z.number().describe("Unix timestamp (ms) when the session was created."),
|
|
466
|
+
lastActiveAt: z.number().describe("Unix timestamp (ms) of the most recent authenticated request (updated when trackLastActive is enabled)."),
|
|
467
|
+
expiresAt: z.number().describe("Unix timestamp (ms) when the session expires."),
|
|
468
|
+
ipAddress: z.string().optional().describe("IP address of the client at session creation."),
|
|
469
|
+
userAgent: z.string().optional().describe("User-agent string of the client at session creation."),
|
|
470
|
+
isActive: z.boolean().describe("Whether the session is currently valid and unexpired."),
|
|
471
|
+
}).openapi("SessionInfo");
|
|
303
472
|
router.use("/auth/sessions", userAuth);
|
|
304
473
|
router.use("/auth/sessions/*", userAuth);
|
|
305
|
-
router.openapi(createRoute({
|
|
474
|
+
router.openapi(withSecurity(createRoute({
|
|
306
475
|
method: "get",
|
|
307
476
|
path: "/auth/sessions",
|
|
477
|
+
summary: "List sessions",
|
|
478
|
+
description: "Returns all sessions for the authenticated user. Includes inactive sessions when `sessionPolicy.includeInactiveSessions` is enabled. Requires a valid session.",
|
|
308
479
|
tags,
|
|
309
480
|
responses: {
|
|
310
481
|
200: {
|
|
311
482
|
content: { "application/json": { schema: z.object({ sessions: z.array(SessionInfoSchema) }) } },
|
|
312
|
-
description: "
|
|
483
|
+
description: "Sessions belonging to the authenticated user.",
|
|
313
484
|
},
|
|
314
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
485
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
315
486
|
},
|
|
316
|
-
}), async (c) => {
|
|
487
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
317
488
|
const userId = c.get("authUserId");
|
|
318
489
|
const sessions = await getUserSessions(userId);
|
|
319
490
|
return c.json({ sessions }, 200);
|
|
320
491
|
});
|
|
321
|
-
router.openapi(createRoute({
|
|
492
|
+
router.openapi(withSecurity(createRoute({
|
|
322
493
|
method: "delete",
|
|
323
494
|
path: "/auth/sessions/{sessionId}",
|
|
495
|
+
summary: "Revoke a session",
|
|
496
|
+
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.",
|
|
324
497
|
tags,
|
|
325
|
-
request: { params: z.object({ sessionId: z.string() }) },
|
|
498
|
+
request: { params: z.object({ sessionId: z.string().describe("UUID of the session to revoke.") }) },
|
|
326
499
|
responses: {
|
|
327
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked" },
|
|
328
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
329
|
-
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found" },
|
|
500
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked successfully." },
|
|
501
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
502
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found or does not belong to the authenticated user." },
|
|
330
503
|
},
|
|
331
|
-
}), async (c) => {
|
|
504
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
332
505
|
const userId = c.get("authUserId");
|
|
333
506
|
const { sessionId } = c.req.valid("param");
|
|
334
507
|
const sessions = await getUserSessions(userId);
|