@lastshotlabs/bunshot 0.0.21 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
package/dist/routes/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRoute, withSecurity } from "../lib/createRoute";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
5
|
import * as AuthService from "../services/auth";
|
|
@@ -6,14 +7,18 @@ import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../sch
|
|
|
6
7
|
import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
|
|
7
8
|
import { userAuth } from "../middleware/userAuth";
|
|
8
9
|
import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
10
|
+
import { isStuffingBlocked, trackFailedLogin } from "../lib/credentialStuffing";
|
|
9
11
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
10
12
|
import { createRouter } from "../lib/context";
|
|
11
|
-
import {
|
|
13
|
+
import { consumeVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
14
|
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
13
|
-
import {
|
|
15
|
+
import { createDeletionCancelToken, consumeDeletionCancelToken } from "../lib/deletionCancelToken";
|
|
16
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName, getBreachedPasswordConfig } from "../lib/appConfig";
|
|
17
|
+
import { checkBreachedPassword } from "../lib/breachedPassword";
|
|
14
18
|
import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
|
|
15
|
-
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
19
|
+
import { getUserSessions, deleteSession, deleteUserSessions, setMfaVerifiedAt } from "../lib/session";
|
|
16
20
|
import { getClientIp } from "../lib/clientIp";
|
|
21
|
+
import { emitSecurityEvent } from "../lib/securityEvents";
|
|
17
22
|
const isProd = process.env.NODE_ENV === "production";
|
|
18
23
|
const TokenResponse = z.object({
|
|
19
24
|
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
@@ -24,7 +29,8 @@ const TokenResponse = z.object({
|
|
|
24
29
|
refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
|
|
25
30
|
mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
|
|
26
31
|
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')."),
|
|
32
|
+
mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp', 'webauthn')."),
|
|
33
|
+
webauthnOptions: z.record(z.string(), z.unknown()).optional().describe("WebAuthn assertion options (present when mfaMethods includes 'webauthn'). Pass directly to navigator.credentials.get()."),
|
|
28
34
|
}).openapi("TokenResponse");
|
|
29
35
|
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
30
36
|
const tags = ["Auth"];
|
|
@@ -35,7 +41,7 @@ const cookieOptions = (maxAge) => ({
|
|
|
35
41
|
path: "/",
|
|
36
42
|
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
37
43
|
});
|
|
38
|
-
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
44
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens, stepUp }) => {
|
|
39
45
|
const router = createRouter();
|
|
40
46
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
41
47
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -64,10 +70,18 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
64
70
|
}), async (c) => {
|
|
65
71
|
const ip = getClientIp(c);
|
|
66
72
|
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
73
|
+
emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
|
|
67
74
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
68
75
|
}
|
|
69
76
|
const body = c.req.valid("json");
|
|
70
77
|
const identifier = body[primaryField];
|
|
78
|
+
const breachConfig = getBreachedPasswordConfig();
|
|
79
|
+
if (breachConfig) {
|
|
80
|
+
const { breached } = await checkBreachedPassword(body.password, breachConfig);
|
|
81
|
+
if (breached && breachConfig.block !== false) {
|
|
82
|
+
throw new HttpError(400, "This password has appeared in a data breach. Please choose a different password.", "BREACHED_PASSWORD");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
71
85
|
const metadata = {
|
|
72
86
|
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
73
87
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
@@ -79,7 +93,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
79
93
|
}
|
|
80
94
|
if (getCsrfEnabled())
|
|
81
95
|
refreshCsrfToken(c);
|
|
82
|
-
|
|
96
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
97
|
+
return c.json(result.refreshToken ? safeResult : result, 201);
|
|
83
98
|
});
|
|
84
99
|
router.openapi(createRoute({
|
|
85
100
|
method: "post",
|
|
@@ -98,11 +113,23 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
98
113
|
const body = c.req.valid("json");
|
|
99
114
|
const identifier = body[primaryField];
|
|
100
115
|
const limitKey = `login:${identifier}`;
|
|
116
|
+
const clientIp = getClientIp(c) ?? "unknown";
|
|
117
|
+
if (isStuffingBlocked(clientIp, identifier)) {
|
|
118
|
+
emitSecurityEvent({
|
|
119
|
+
eventType: "security.credential_stuffing.detected",
|
|
120
|
+
severity: "critical",
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
ip: clientIp,
|
|
123
|
+
meta: { identifier },
|
|
124
|
+
});
|
|
125
|
+
throw new HttpError(429, "Too many login attempts from this source", "CREDENTIAL_STUFFING_BLOCKED");
|
|
126
|
+
}
|
|
101
127
|
if (await isLimited(limitKey, loginOpts)) {
|
|
128
|
+
emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
|
|
102
129
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
103
130
|
}
|
|
104
131
|
const metadata = {
|
|
105
|
-
ipAddress:
|
|
132
|
+
ipAddress: clientIp !== "unknown" ? clientIp : undefined,
|
|
106
133
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
107
134
|
};
|
|
108
135
|
try {
|
|
@@ -116,9 +143,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
116
143
|
if (getCsrfEnabled())
|
|
117
144
|
refreshCsrfToken(c);
|
|
118
145
|
}
|
|
119
|
-
|
|
146
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
147
|
+
return c.json(result.refreshToken ? safeResult : result, 200);
|
|
120
148
|
}
|
|
121
149
|
catch (err) {
|
|
150
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
151
|
+
trackFailedLogin(clientIp, identifier);
|
|
152
|
+
}
|
|
122
153
|
await trackAttempt(limitKey, loginOpts); // failure — count it
|
|
123
154
|
throw err;
|
|
124
155
|
}
|
|
@@ -212,6 +243,33 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
212
243
|
if (accountDeletion?.onBeforeDelete) {
|
|
213
244
|
await accountDeletion.onBeforeDelete(authUserId);
|
|
214
245
|
}
|
|
246
|
+
// Queued deletion via BullMQ
|
|
247
|
+
if (accountDeletion?.queued) {
|
|
248
|
+
const { createQueue } = await import("../lib/queue");
|
|
249
|
+
const appName = getAppName();
|
|
250
|
+
const queue = createQueue(`${appName}:account-deletions`);
|
|
251
|
+
const delayMs = (accountDeletion.gracePeriod ?? 0) * 1000;
|
|
252
|
+
const job = await queue.add("delete-account", { userId: authUserId }, {
|
|
253
|
+
delay: delayMs,
|
|
254
|
+
attempts: 3,
|
|
255
|
+
backoff: { type: "exponential", delay: 1000 },
|
|
256
|
+
removeOnComplete: true,
|
|
257
|
+
removeOnFail: 100,
|
|
258
|
+
});
|
|
259
|
+
await queue.close();
|
|
260
|
+
// Revoke sessions immediately so the user is logged out
|
|
261
|
+
await deleteUserSessions(authUserId);
|
|
262
|
+
if (accountDeletion.gracePeriod && accountDeletion.onDeletionScheduled) {
|
|
263
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
264
|
+
const email = user?.email ?? "";
|
|
265
|
+
if (email) {
|
|
266
|
+
const cancelToken = await createDeletionCancelToken(authUserId, job.id, accountDeletion.gracePeriod);
|
|
267
|
+
await accountDeletion.onDeletionScheduled(authUserId, email, cancelToken);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
271
|
+
return c.json({ message: "Account deletion scheduled" }, 202);
|
|
272
|
+
}
|
|
215
273
|
// Synchronous deletion (default)
|
|
216
274
|
await deleteUserSessions(authUserId);
|
|
217
275
|
await adapter.deleteUser(authUserId);
|
|
@@ -226,13 +284,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
226
284
|
method: "post",
|
|
227
285
|
path: "/auth/set-password",
|
|
228
286
|
summary: "Set or update password",
|
|
229
|
-
description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. Requires a valid session.",
|
|
287
|
+
description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. If the account already has a password set, `currentPassword` is required. Requires a valid session.",
|
|
230
288
|
tags,
|
|
231
|
-
request: {
|
|
289
|
+
request: {
|
|
290
|
+
body: {
|
|
291
|
+
content: {
|
|
292
|
+
"application/json": {
|
|
293
|
+
schema: z.object({
|
|
294
|
+
password: z.string().min(8).describe("New password."),
|
|
295
|
+
currentPassword: z.string().optional().describe("Current password. Required if the account already has a password set."),
|
|
296
|
+
}),
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
description: "New password.",
|
|
300
|
+
},
|
|
301
|
+
},
|
|
232
302
|
responses: {
|
|
233
303
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
|
|
234
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error
|
|
235
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
304
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or current password is required when one is already set." },
|
|
305
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session, or current password is incorrect." },
|
|
236
306
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
237
307
|
},
|
|
238
308
|
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
@@ -240,10 +310,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
240
310
|
if (!adapter.setPassword) {
|
|
241
311
|
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
242
312
|
}
|
|
243
|
-
const { password } = c.req.valid("json");
|
|
313
|
+
const { password, currentPassword } = c.req.valid("json");
|
|
244
314
|
const authUserId = c.get("authUserId");
|
|
315
|
+
// If the user already has a password, require currentPassword to change it
|
|
316
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
317
|
+
if (user) {
|
|
318
|
+
const findFn = adapter.findByIdentifier ?? (user.email ? adapter.findByEmail.bind(adapter) : null);
|
|
319
|
+
if (findFn && user.email) {
|
|
320
|
+
const found = await findFn(user.email);
|
|
321
|
+
if (found?.passwordHash) {
|
|
322
|
+
if (!currentPassword) {
|
|
323
|
+
return c.json({ error: "Current password is required to change an existing password." }, 400);
|
|
324
|
+
}
|
|
325
|
+
if (!(await Bun.password.verify(currentPassword, found.passwordHash))) {
|
|
326
|
+
return c.json({ error: "Current password is incorrect." }, 401);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
245
331
|
const passwordHash = await Bun.password.hash(password);
|
|
246
332
|
await adapter.setPassword(authUserId, passwordHash);
|
|
333
|
+
emitSecurityEvent({ eventType: "auth.password.change", severity: "info", timestamp: new Date().toISOString() });
|
|
247
334
|
return c.json({ message: "Password updated" }, 200);
|
|
248
335
|
});
|
|
249
336
|
router.openapi(createRoute({
|
|
@@ -285,24 +372,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
285
372
|
}
|
|
286
373
|
const { token } = c.req.valid("json");
|
|
287
374
|
const adapter = getAuthAdapter();
|
|
288
|
-
const entry = await
|
|
375
|
+
const entry = await consumeVerificationToken(token);
|
|
289
376
|
if (!entry)
|
|
290
377
|
return c.json({ error: "Invalid or expired verification token" }, 400);
|
|
291
378
|
if (adapter.setEmailVerified)
|
|
292
379
|
await adapter.setEmailVerified(entry.userId, true);
|
|
293
|
-
|
|
380
|
+
if (getCsrfEnabled())
|
|
381
|
+
refreshCsrfToken(c);
|
|
294
382
|
return c.json({ message: "Email verified" }, 200);
|
|
295
383
|
});
|
|
296
384
|
router.openapi(createRoute({
|
|
297
385
|
method: "post",
|
|
298
386
|
path: "/auth/resend-verification",
|
|
299
387
|
summary: "Resend verification email",
|
|
300
|
-
description: "Authenticates with credentials and sends a new verification email.
|
|
388
|
+
description: "Authenticates with credentials and sends a new verification email. Always returns 200 for valid credentials regardless of verification status, to prevent user enumeration. Rate-limited per identifier. Does not require a session.",
|
|
301
389
|
tags,
|
|
302
390
|
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
|
|
303
391
|
responses: {
|
|
304
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
305
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
392
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent, or account is already verified (indistinguishable by design)." },
|
|
393
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "No email address on file for this account." },
|
|
306
394
|
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
307
395
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
|
|
308
396
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
@@ -323,8 +411,10 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
323
411
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
324
412
|
}
|
|
325
413
|
const alreadyVerified = await adapter.getEmailVerified(user.id);
|
|
414
|
+
// Return 200 (not 400) to avoid revealing whether the account is verified —
|
|
415
|
+
// distinguishing verified vs unverified would let attackers confirm valid credentials.
|
|
326
416
|
if (alreadyVerified)
|
|
327
|
-
return c.json({
|
|
417
|
+
return c.json({ message: "Verification email sent if not already verified" }, 200);
|
|
328
418
|
const fullUser = await adapter.getUser(user.id);
|
|
329
419
|
if (!fullUser?.email)
|
|
330
420
|
return c.json({ error: "No email address on file" }, 400);
|
|
@@ -405,6 +495,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
405
495
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
406
496
|
}
|
|
407
497
|
const { token, password } = c.req.valid("json");
|
|
498
|
+
const breachConfig = getBreachedPasswordConfig();
|
|
499
|
+
if (breachConfig) {
|
|
500
|
+
const { breached } = await checkBreachedPassword(password, breachConfig);
|
|
501
|
+
if (breached && breachConfig.block !== false) {
|
|
502
|
+
throw new HttpError(400, "This password has appeared in a data breach. Please choose a different password.", "BREACHED_PASSWORD");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
408
505
|
// consumeResetToken atomically gets and deletes — prevents concurrent replay
|
|
409
506
|
const entry = await consumeResetToken(token);
|
|
410
507
|
if (!entry)
|
|
@@ -418,6 +515,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
418
515
|
// Revoke all sessions so stolen JWTs can't stay valid after a reset
|
|
419
516
|
const sessions = await getUserSessions(entry.userId);
|
|
420
517
|
await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
|
|
518
|
+
emitSecurityEvent({ eventType: "auth.password.reset", severity: "info", timestamp: new Date().toISOString() });
|
|
421
519
|
return c.json({ message: "Password reset successfully" }, 200);
|
|
422
520
|
});
|
|
423
521
|
}
|
|
@@ -427,7 +525,6 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
427
525
|
if (refreshTokens) {
|
|
428
526
|
const RefreshResponse = z.object({
|
|
429
527
|
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
528
|
userId: z.string().describe("Unique user ID."),
|
|
432
529
|
}).openapi("RefreshResponse");
|
|
433
530
|
router.openapi(createRoute({
|
|
@@ -466,7 +563,127 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
466
563
|
const result = await AuthService.refresh(rt);
|
|
467
564
|
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
|
|
468
565
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
469
|
-
|
|
566
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
567
|
+
return c.json(safeResult, 200);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Account deletion cancellation — only mounted when queued: true + gracePeriod > 0
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
if (accountDeletion?.queued && accountDeletion.gracePeriod) {
|
|
574
|
+
router.openapi(createRoute({
|
|
575
|
+
method: "post",
|
|
576
|
+
path: "/auth/cancel-deletion",
|
|
577
|
+
summary: "Cancel scheduled account deletion",
|
|
578
|
+
description: "Cancels a pending queued account deletion using the cancel token sent via the onDeletionScheduled callback. Must be called before the grace period expires.",
|
|
579
|
+
tags,
|
|
580
|
+
request: {
|
|
581
|
+
body: {
|
|
582
|
+
content: {
|
|
583
|
+
"application/json": {
|
|
584
|
+
schema: z.object({
|
|
585
|
+
token: z.string().describe("Cancel token received in the deletion scheduled notification."),
|
|
586
|
+
}),
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
description: "Cancel token.",
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
responses: {
|
|
593
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion cancelled." },
|
|
594
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired cancel token." },
|
|
595
|
+
},
|
|
596
|
+
}), async (c) => {
|
|
597
|
+
const { token } = c.req.valid("json");
|
|
598
|
+
const entry = await consumeDeletionCancelToken(token);
|
|
599
|
+
if (!entry)
|
|
600
|
+
return c.json({ error: "Invalid or expired cancel token" }, 400);
|
|
601
|
+
// Remove the pending BullMQ job
|
|
602
|
+
try {
|
|
603
|
+
const { createQueue } = await import("../lib/queue");
|
|
604
|
+
const appName = getAppName();
|
|
605
|
+
const queue = createQueue(`${appName}:account-deletions`);
|
|
606
|
+
const job = await queue.getJob(entry.jobId);
|
|
607
|
+
if (job)
|
|
608
|
+
await job.remove();
|
|
609
|
+
await queue.close();
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Job may have already executed — that's an error case but we still consumed the token
|
|
613
|
+
}
|
|
614
|
+
return c.json({ message: "Account deletion cancelled" }, 200);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// Step-up MFA re-authentication — only mounted when stepUp is configured
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
if (stepUp) {
|
|
621
|
+
const stepUpOpts = { windowMs: rateLimit?.mfaVerify?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.mfaVerify?.max ?? 10 };
|
|
622
|
+
const stepUpSchema = z.object({
|
|
623
|
+
mfaCode: z.string().optional().describe("6-digit TOTP code or recovery code."),
|
|
624
|
+
password: z.string().optional().describe("Account password."),
|
|
625
|
+
}).refine(data => data.mfaCode || data.password, {
|
|
626
|
+
message: "Either mfaCode or password is required",
|
|
627
|
+
});
|
|
628
|
+
router.use("/auth/step-up", userAuth);
|
|
629
|
+
router.openapi(withSecurity(createRoute({
|
|
630
|
+
method: "post",
|
|
631
|
+
path: "/auth/step-up",
|
|
632
|
+
summary: "Step-up MFA re-authentication",
|
|
633
|
+
description: "Re-authenticates the current session via TOTP code or password to satisfy step-up requirements for sensitive operations. On success, sets mfaVerifiedAt in the session.",
|
|
634
|
+
tags,
|
|
635
|
+
request: {
|
|
636
|
+
body: {
|
|
637
|
+
content: {
|
|
638
|
+
"application/json": {
|
|
639
|
+
schema: stepUpSchema,
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
description: "TOTP code or password for re-authentication.",
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
responses: {
|
|
646
|
+
200: { content: { "application/json": { schema: z.object({ ok: z.literal(true) }) } }, description: "Step-up authentication successful." },
|
|
647
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Neither mfaCode nor password provided." },
|
|
648
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code or password, or no valid session." },
|
|
649
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many step-up attempts. Try again later." },
|
|
650
|
+
},
|
|
651
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
652
|
+
const ip = getClientIp(c);
|
|
653
|
+
if (await trackAttempt(`step-up:${ip}`, stepUpOpts)) {
|
|
654
|
+
return c.json({ error: "Too many step-up attempts. Try again later." }, 429);
|
|
655
|
+
}
|
|
656
|
+
const userId = c.get("authUserId");
|
|
657
|
+
const sessionId = c.get("sessionId");
|
|
658
|
+
const { mfaCode, password } = c.req.valid("json");
|
|
659
|
+
let valid = false;
|
|
660
|
+
if (mfaCode) {
|
|
661
|
+
const { verifyTotp, verifyRecoveryCode } = await import("../services/mfa");
|
|
662
|
+
valid = await verifyTotp(userId, mfaCode);
|
|
663
|
+
if (!valid)
|
|
664
|
+
valid = await verifyRecoveryCode(userId, mfaCode);
|
|
665
|
+
}
|
|
666
|
+
else if (password) {
|
|
667
|
+
const adapter = getAuthAdapter();
|
|
668
|
+
const findFn = adapter.findByIdentifier ?? (adapter.findByEmail ? adapter.findByEmail.bind(adapter) : null);
|
|
669
|
+
if (findFn) {
|
|
670
|
+
const user = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
671
|
+
const identifier = user?.email ?? "";
|
|
672
|
+
if (identifier) {
|
|
673
|
+
const found = await findFn(identifier);
|
|
674
|
+
if (found?.passwordHash) {
|
|
675
|
+
valid = await Bun.password.verify(password, found.passwordHash);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (!valid) {
|
|
681
|
+
emitSecurityEvent({ eventType: "auth.step_up.failure", severity: "warn", timestamp: new Date().toISOString(), userId });
|
|
682
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
683
|
+
}
|
|
684
|
+
await setMfaVerifiedAt(sessionId);
|
|
685
|
+
emitSecurityEvent({ eventType: "auth.step_up.success", severity: "info", timestamp: new Date().toISOString(), userId });
|
|
686
|
+
return c.json({ ok: true }, 200);
|
|
470
687
|
});
|
|
471
688
|
}
|
|
472
689
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface GroupsManagementConfig {
|
|
4
|
+
/**
|
|
5
|
+
* Role required to access all management routes.
|
|
6
|
+
* Applied via requireRole.global(adminRole). Default: "admin".
|
|
7
|
+
* Ignored if `middleware` is provided.
|
|
8
|
+
*/
|
|
9
|
+
adminRole?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Fully replaces the default auth middleware stack [userAuth, requireRole.global(adminRole)].
|
|
12
|
+
* Use only when a single role check is insufficient.
|
|
13
|
+
* When provided, `adminRole` is ignored.
|
|
14
|
+
*/
|
|
15
|
+
middleware?: MiddlewareHandler<AppEnv>[];
|
|
16
|
+
}
|
|
17
|
+
export interface GroupsConfig {
|
|
18
|
+
/** Mount group management routes. Pass `true` to use all defaults. */
|
|
19
|
+
managementRoutes?: GroupsManagementConfig | true;
|
|
20
|
+
}
|
|
21
|
+
export declare const createGroupsRouter: (config: GroupsConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;
|