@lastshotlabs/bunshot 0.0.25 → 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/dist/adapters/localStorage.js +20 -5
- package/dist/adapters/memoryAuth.d.ts +6 -0
- package/dist/adapters/memoryAuth.js +117 -2
- package/dist/adapters/mongoAuth.js +97 -1
- package/dist/adapters/sqliteAuth.d.ts +23 -0
- package/dist/adapters/sqliteAuth.js +153 -2
- package/dist/app.d.ts +105 -2
- package/dist/app.js +112 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +13 -2
- package/dist/lib/HttpError.d.ts +2 -1
- package/dist/lib/HttpError.js +3 -1
- package/dist/lib/appConfig.d.ts +113 -0
- package/dist/lib/appConfig.js +38 -0
- package/dist/lib/auditLog.d.ts +6 -0
- package/dist/lib/auditLog.js +17 -0
- package/dist/lib/authAdapter.d.ts +71 -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/context.d.ts +5 -0
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- 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/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/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 +10 -0
- package/dist/lib/session.js +67 -5
- package/dist/lib/signing.js +5 -2
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/upload.d.ts +4 -0
- package/dist/lib/upload.js +26 -1
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/ws.js +7 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +8 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +40 -13
- package/dist/middleware/requestSigning.js +6 -5
- package/dist/middleware/requireMfaSetup.js +2 -1
- 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/webhookAuth.d.ts +1 -1
- package/dist/middleware/webhookAuth.js +6 -5
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -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 +155 -16
- package/dist/routes/jobs.js +21 -3
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +1 -0
- package/dist/routes/metrics.js +3 -0
- package/dist/routes/mfa.js +9 -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 +13 -1
- package/dist/routes/uploads.js +98 -6
- 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 +2 -1
- package/docs/sections/auth-flow/full.md +790 -779
- package/docs/sections/auth-security-examples/full.md +23 -0
- package/docs/sections/metrics/full.md +6 -2
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/uploads/full.md +11 -2
- package/docs/sections/webhook-auth/full.md +1 -1
- package/docs/sections/websocket/full.md +12 -0
- package/package.json +3 -2
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,15 +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
15
|
import { createDeletionCancelToken, consumeDeletionCancelToken } from "../lib/deletionCancelToken";
|
|
14
|
-
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName } from "../lib/appConfig";
|
|
16
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName, getBreachedPasswordConfig } from "../lib/appConfig";
|
|
17
|
+
import { checkBreachedPassword } from "../lib/breachedPassword";
|
|
15
18
|
import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
|
|
16
|
-
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
19
|
+
import { getUserSessions, deleteSession, deleteUserSessions, setMfaVerifiedAt } from "../lib/session";
|
|
17
20
|
import { getClientIp } from "../lib/clientIp";
|
|
21
|
+
import { emitSecurityEvent } from "../lib/securityEvents";
|
|
18
22
|
const isProd = process.env.NODE_ENV === "production";
|
|
19
23
|
const TokenResponse = z.object({
|
|
20
24
|
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
@@ -37,7 +41,7 @@ const cookieOptions = (maxAge) => ({
|
|
|
37
41
|
path: "/",
|
|
38
42
|
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
39
43
|
});
|
|
40
|
-
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
44
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens, stepUp }) => {
|
|
41
45
|
const router = createRouter();
|
|
42
46
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
43
47
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -66,10 +70,18 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
66
70
|
}), async (c) => {
|
|
67
71
|
const ip = getClientIp(c);
|
|
68
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 } });
|
|
69
74
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
70
75
|
}
|
|
71
76
|
const body = c.req.valid("json");
|
|
72
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
|
+
}
|
|
73
85
|
const metadata = {
|
|
74
86
|
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
75
87
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
@@ -81,7 +93,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
81
93
|
}
|
|
82
94
|
if (getCsrfEnabled())
|
|
83
95
|
refreshCsrfToken(c);
|
|
84
|
-
|
|
96
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
97
|
+
return c.json(result.refreshToken ? safeResult : result, 201);
|
|
85
98
|
});
|
|
86
99
|
router.openapi(createRoute({
|
|
87
100
|
method: "post",
|
|
@@ -100,11 +113,23 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
100
113
|
const body = c.req.valid("json");
|
|
101
114
|
const identifier = body[primaryField];
|
|
102
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
|
+
}
|
|
103
127
|
if (await isLimited(limitKey, loginOpts)) {
|
|
128
|
+
emitSecurityEvent({ eventType: "security.rate_limit.exceeded", severity: "warn", timestamp: new Date().toISOString(), meta: { path: c.req.path } });
|
|
104
129
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
105
130
|
}
|
|
106
131
|
const metadata = {
|
|
107
|
-
ipAddress:
|
|
132
|
+
ipAddress: clientIp !== "unknown" ? clientIp : undefined,
|
|
108
133
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
109
134
|
};
|
|
110
135
|
try {
|
|
@@ -118,9 +143,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
118
143
|
if (getCsrfEnabled())
|
|
119
144
|
refreshCsrfToken(c);
|
|
120
145
|
}
|
|
121
|
-
|
|
146
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
147
|
+
return c.json(result.refreshToken ? safeResult : result, 200);
|
|
122
148
|
}
|
|
123
149
|
catch (err) {
|
|
150
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
151
|
+
trackFailedLogin(clientIp, identifier);
|
|
152
|
+
}
|
|
124
153
|
await trackAttempt(limitKey, loginOpts); // failure — count it
|
|
125
154
|
throw err;
|
|
126
155
|
}
|
|
@@ -255,13 +284,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
255
284
|
method: "post",
|
|
256
285
|
path: "/auth/set-password",
|
|
257
286
|
summary: "Set or update password",
|
|
258
|
-
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.",
|
|
259
288
|
tags,
|
|
260
|
-
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
|
+
},
|
|
261
302
|
responses: {
|
|
262
303
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
|
|
263
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error
|
|
264
|
-
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." },
|
|
265
306
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
266
307
|
},
|
|
267
308
|
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
@@ -269,10 +310,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
269
310
|
if (!adapter.setPassword) {
|
|
270
311
|
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
271
312
|
}
|
|
272
|
-
const { password } = c.req.valid("json");
|
|
313
|
+
const { password, currentPassword } = c.req.valid("json");
|
|
273
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
|
+
}
|
|
274
331
|
const passwordHash = await Bun.password.hash(password);
|
|
275
332
|
await adapter.setPassword(authUserId, passwordHash);
|
|
333
|
+
emitSecurityEvent({ eventType: "auth.password.change", severity: "info", timestamp: new Date().toISOString() });
|
|
276
334
|
return c.json({ message: "Password updated" }, 200);
|
|
277
335
|
});
|
|
278
336
|
router.openapi(createRoute({
|
|
@@ -314,12 +372,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
314
372
|
}
|
|
315
373
|
const { token } = c.req.valid("json");
|
|
316
374
|
const adapter = getAuthAdapter();
|
|
317
|
-
const entry = await
|
|
375
|
+
const entry = await consumeVerificationToken(token);
|
|
318
376
|
if (!entry)
|
|
319
377
|
return c.json({ error: "Invalid or expired verification token" }, 400);
|
|
320
378
|
if (adapter.setEmailVerified)
|
|
321
379
|
await adapter.setEmailVerified(entry.userId, true);
|
|
322
|
-
|
|
380
|
+
if (getCsrfEnabled())
|
|
381
|
+
refreshCsrfToken(c);
|
|
323
382
|
return c.json({ message: "Email verified" }, 200);
|
|
324
383
|
});
|
|
325
384
|
router.openapi(createRoute({
|
|
@@ -436,6 +495,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
436
495
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
437
496
|
}
|
|
438
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
|
+
}
|
|
439
505
|
// consumeResetToken atomically gets and deletes — prevents concurrent replay
|
|
440
506
|
const entry = await consumeResetToken(token);
|
|
441
507
|
if (!entry)
|
|
@@ -449,6 +515,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
449
515
|
// Revoke all sessions so stolen JWTs can't stay valid after a reset
|
|
450
516
|
const sessions = await getUserSessions(entry.userId);
|
|
451
517
|
await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
|
|
518
|
+
emitSecurityEvent({ eventType: "auth.password.reset", severity: "info", timestamp: new Date().toISOString() });
|
|
452
519
|
return c.json({ message: "Password reset successfully" }, 200);
|
|
453
520
|
});
|
|
454
521
|
}
|
|
@@ -458,7 +525,6 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
458
525
|
if (refreshTokens) {
|
|
459
526
|
const RefreshResponse = z.object({
|
|
460
527
|
token: z.string().describe("New short-lived JWT access token."),
|
|
461
|
-
refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
|
|
462
528
|
userId: z.string().describe("Unique user ID."),
|
|
463
529
|
}).openapi("RefreshResponse");
|
|
464
530
|
router.openapi(createRoute({
|
|
@@ -497,7 +563,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
497
563
|
const result = await AuthService.refresh(rt);
|
|
498
564
|
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
|
|
499
565
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
500
|
-
|
|
566
|
+
const { refreshToken: _rt, ...safeResult } = result;
|
|
567
|
+
return c.json(safeResult, 200);
|
|
501
568
|
});
|
|
502
569
|
}
|
|
503
570
|
// ---------------------------------------------------------------------------
|
|
@@ -548,6 +615,78 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
548
615
|
});
|
|
549
616
|
}
|
|
550
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);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
551
690
|
// Session management
|
|
552
691
|
// ---------------------------------------------------------------------------
|
|
553
692
|
const SessionInfoSchema = z.object({
|
package/dist/routes/jobs.js
CHANGED
|
@@ -21,6 +21,9 @@ export const createJobsRouter = (config) => {
|
|
|
21
21
|
const allowedQueues = new Set(config.allowedQueues ?? []);
|
|
22
22
|
const authConfig = config.auth ?? "none";
|
|
23
23
|
const scopeToUser = config.scopeToUser ?? false;
|
|
24
|
+
if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
|
|
25
|
+
throw new Error("[security] jobs.auth is required in production. Set jobs.auth or set unsafePublic: true.");
|
|
26
|
+
}
|
|
24
27
|
// Determine if userAuth is involved (for scopeToUser and OpenAPI security schemes)
|
|
25
28
|
const hasUserAuth = authConfig === "userAuth" || Array.isArray(authConfig);
|
|
26
29
|
// Apply middleware based on config
|
|
@@ -141,7 +144,9 @@ export const createJobsRouter = (config) => {
|
|
|
141
144
|
filteredJobs = jobs.filter((job) => job.data?.userId === userId);
|
|
142
145
|
}
|
|
143
146
|
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
144
|
-
|
|
147
|
+
// NOTE: When scopeToUser is active, total is a page-local filtered count, not a
|
|
148
|
+
// globally accurate total. BullMQ does not support server-side user filtering.
|
|
149
|
+
return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
|
|
145
150
|
});
|
|
146
151
|
// ─── Dead letter queue ────────────────────────────────────────────────
|
|
147
152
|
// Must be registered BEFORE getJobRoute so that the literal path segment
|
|
@@ -187,8 +192,15 @@ export const createJobsRouter = (config) => {
|
|
|
187
192
|
dlqQueue.getWaiting(start, end),
|
|
188
193
|
dlqQueue.getWaitingCount(),
|
|
189
194
|
]);
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
let filteredJobs = jobs;
|
|
196
|
+
if (scopeToUser && hasUserAuth) {
|
|
197
|
+
const userId = c.get("authUserId");
|
|
198
|
+
filteredJobs = jobs.filter((job) => job.data?.userId === userId);
|
|
199
|
+
}
|
|
200
|
+
const result = await Promise.all(filteredJobs.map(jobToResponse));
|
|
201
|
+
// NOTE: When scopeToUser is active, total is a page-local filtered count, not a
|
|
202
|
+
// globally accurate total. BullMQ does not support server-side user filtering.
|
|
203
|
+
return c.json({ jobs: result, total: scopeToUser && hasUserAuth ? filteredJobs.length : total }, 200);
|
|
192
204
|
});
|
|
193
205
|
// ─── Get job status ─────────────────────────────────────────────────────
|
|
194
206
|
const getJobRoute = createRoute({
|
|
@@ -265,6 +277,12 @@ export const createJobsRouter = (config) => {
|
|
|
265
277
|
const job = await queue.getJob(id);
|
|
266
278
|
if (!job)
|
|
267
279
|
return c.json({ error: "Job not found" }, 404);
|
|
280
|
+
if (scopeToUser && hasUserAuth) {
|
|
281
|
+
const userId = c.get("authUserId");
|
|
282
|
+
if (job.data?.userId !== userId) {
|
|
283
|
+
return c.json({ error: "Job not found" }, 404);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
268
286
|
const { logs, count } = await queue.getJobLogs(id);
|
|
269
287
|
return c.json({ logs, count }, 200);
|
|
270
288
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getAuthAdapter } from "../lib/authAdapter";
|
|
3
|
+
import { signToken } from "../lib/jwt";
|
|
4
|
+
import { HttpError } from "../lib/HttpError";
|
|
5
|
+
import { getM2MTokenExpiry } from "../lib/appConfig";
|
|
6
|
+
export function createM2MRouter() {
|
|
7
|
+
const router = new Hono();
|
|
8
|
+
/**
|
|
9
|
+
* POST /oauth/token
|
|
10
|
+
* OAuth 2.0 client_credentials grant
|
|
11
|
+
*/
|
|
12
|
+
router.post("/oauth/token", async (c) => {
|
|
13
|
+
let body;
|
|
14
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
15
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
16
|
+
const text = await c.req.text();
|
|
17
|
+
const params = new URLSearchParams(text);
|
|
18
|
+
body = {
|
|
19
|
+
grant_type: params.get("grant_type") ?? undefined,
|
|
20
|
+
client_id: params.get("client_id") ?? undefined,
|
|
21
|
+
client_secret: params.get("client_secret") ?? undefined,
|
|
22
|
+
scope: params.get("scope") ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
try {
|
|
27
|
+
body = await c.req.json();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new HttpError(400, "Invalid request body");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (body.grant_type !== "client_credentials") {
|
|
34
|
+
throw new HttpError(400, "Unsupported grant type", "UNSUPPORTED_GRANT_TYPE");
|
|
35
|
+
}
|
|
36
|
+
const { client_id: clientId, client_secret: clientSecret } = body;
|
|
37
|
+
if (!clientId || !clientSecret) {
|
|
38
|
+
throw new HttpError(400, "client_id and client_secret are required");
|
|
39
|
+
}
|
|
40
|
+
const adapter = getAuthAdapter();
|
|
41
|
+
if (!adapter.getM2MClient) {
|
|
42
|
+
throw new HttpError(501, "M2M client credentials not supported by auth adapter");
|
|
43
|
+
}
|
|
44
|
+
const client = await adapter.getM2MClient(clientId);
|
|
45
|
+
if (!client) {
|
|
46
|
+
throw new HttpError(401, "Invalid client credentials");
|
|
47
|
+
}
|
|
48
|
+
const secretValid = await Bun.password.verify(clientSecret, client.clientSecretHash);
|
|
49
|
+
if (!secretValid) {
|
|
50
|
+
throw new HttpError(401, "Invalid client credentials");
|
|
51
|
+
}
|
|
52
|
+
// Validate requested scopes against client's allowed scopes
|
|
53
|
+
let grantedScopes = client.scopes;
|
|
54
|
+
if (body.scope) {
|
|
55
|
+
const requested = body.scope.split(" ");
|
|
56
|
+
const invalid = requested.filter((s) => !client.scopes.includes(s));
|
|
57
|
+
if (invalid.length > 0) {
|
|
58
|
+
throw new HttpError(400, `Scope not allowed: ${invalid.join(", ")}`, "INVALID_SCOPE");
|
|
59
|
+
}
|
|
60
|
+
grantedScopes = requested;
|
|
61
|
+
}
|
|
62
|
+
const expiry = getM2MTokenExpiry();
|
|
63
|
+
const token = await signToken({ sub: clientId, scope: grantedScopes.join(" ") }, expiry);
|
|
64
|
+
return c.json({
|
|
65
|
+
access_token: token,
|
|
66
|
+
token_type: "Bearer",
|
|
67
|
+
expires_in: expiry,
|
|
68
|
+
scope: grantedScopes.join(" "),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return router;
|
|
72
|
+
}
|
package/dist/routes/metrics.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ import type { AppEnv } from "../lib/context";
|
|
|
3
3
|
export interface MetricsRouteConfig {
|
|
4
4
|
auth?: "userAuth" | "none" | MiddlewareHandler<AppEnv>[];
|
|
5
5
|
queues?: string[];
|
|
6
|
+
unsafePublic?: boolean;
|
|
6
7
|
}
|
|
7
8
|
export declare const createMetricsRouter: (config: MetricsRouteConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;
|
package/dist/routes/metrics.js
CHANGED
|
@@ -4,6 +4,9 @@ import { userAuth } from "../middleware/userAuth";
|
|
|
4
4
|
export const createMetricsRouter = (config) => {
|
|
5
5
|
const router = createRouter();
|
|
6
6
|
const authConfig = config.auth ?? "none";
|
|
7
|
+
if (process.env.NODE_ENV === "production" && authConfig === "none" && !config.unsafePublic) {
|
|
8
|
+
throw new Error("[security] metrics.auth is required in production. Set metrics.auth or set unsafePublic: true.");
|
|
9
|
+
}
|
|
7
10
|
// Apply auth middleware
|
|
8
11
|
if (authConfig === "userAuth") {
|
|
9
12
|
router.use("/metrics", userAuth);
|
package/dist/routes/mfa.js
CHANGED
|
@@ -8,10 +8,12 @@ import * as AuthService from "../services/auth";
|
|
|
8
8
|
import { consumeMfaChallenge, replaceMfaChallengeOtp } from "../lib/mfaChallenge";
|
|
9
9
|
import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
|
|
10
10
|
import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getCsrfEnabled } from "../lib/appConfig";
|
|
11
|
+
import { setMfaVerifiedAt } from "../lib/session";
|
|
11
12
|
import { refreshCsrfToken } from "../middleware/csrf";
|
|
12
13
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
13
14
|
import { trackAttempt } from "../lib/authRateLimit";
|
|
14
15
|
import { getClientIp } from "../lib/clientIp";
|
|
16
|
+
import { emitSecurityEvent } from "../lib/securityEvents";
|
|
15
17
|
const isProd = process.env.NODE_ENV === "production";
|
|
16
18
|
const cookieOptions = (maxAge) => ({
|
|
17
19
|
httpOnly: true,
|
|
@@ -105,6 +107,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
|
|
|
105
107
|
const userId = c.get("authUserId");
|
|
106
108
|
const { code } = c.req.valid("json");
|
|
107
109
|
const recoveryCodes = await MfaService.verifySetup(userId, code);
|
|
110
|
+
emitSecurityEvent({ eventType: "auth.mfa.setup", severity: "info", timestamp: new Date().toISOString(), userId: c.get("authUserId") ?? undefined });
|
|
108
111
|
return c.json({ message: "MFA enabled", recoveryCodes }, 200);
|
|
109
112
|
});
|
|
110
113
|
// ─── Verify (complete login after password) ───────────────────────────────
|
|
@@ -185,13 +188,17 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
|
|
|
185
188
|
if (!valid && code) {
|
|
186
189
|
valid = await MfaService.verifyRecoveryCode(userId, code);
|
|
187
190
|
}
|
|
188
|
-
if (!valid)
|
|
191
|
+
if (!valid) {
|
|
192
|
+
emitSecurityEvent({ eventType: "auth.mfa.verify.failure", severity: "warn", timestamp: new Date().toISOString() });
|
|
189
193
|
return c.json({ error: "Invalid MFA code" }, 401);
|
|
194
|
+
}
|
|
190
195
|
// Create session — reuse the service helper for refresh token support
|
|
191
196
|
const result = await AuthService.createSessionForUser(userId, {
|
|
192
197
|
ipAddress: getClientIp(c),
|
|
193
198
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
194
199
|
});
|
|
200
|
+
// Mark MFA as verified on the new session so step-up is satisfied immediately
|
|
201
|
+
await setMfaVerifiedAt(result.sessionId);
|
|
195
202
|
const rtConfig = getRefreshTokenConfig();
|
|
196
203
|
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
|
|
197
204
|
if (result.refreshToken) {
|
|
@@ -199,6 +206,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
|
|
|
199
206
|
}
|
|
200
207
|
if (getCsrfEnabled())
|
|
201
208
|
refreshCsrfToken(c);
|
|
209
|
+
emitSecurityEvent({ eventType: "auth.mfa.verify.success", severity: "info", timestamp: new Date().toISOString() });
|
|
202
210
|
return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
|
|
203
211
|
});
|
|
204
212
|
// ─── Disable MFA ──────────────────────────────────────────────────────────
|
package/dist/routes/oauth.js
CHANGED
|
@@ -25,6 +25,12 @@ const cookieOptions = (maxAge) => ({
|
|
|
25
25
|
});
|
|
26
26
|
const tags = ["OAuth"];
|
|
27
27
|
const OAuthErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("OAuthErrorResponse");
|
|
28
|
+
// `postLoginRedirect` is always sourced from server-side config (passed into
|
|
29
|
+
// `createOAuthRouter` at startup) and is never derived from user-supplied input
|
|
30
|
+
// or OAuth callback query parameters. No runtime allowlist validation is required
|
|
31
|
+
// because the value is not attacker-controlled. The `auth.oauth.allowedRedirectUrls`
|
|
32
|
+
// config is available for consuming apps that want to enforce an explicit allowlist
|
|
33
|
+
// at the framework level if they ever pass a dynamic value here.
|
|
28
34
|
const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
|
|
29
35
|
const adapter = getAuthAdapter();
|
|
30
36
|
if (!adapter.findOrCreateByProvider) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getJwks } from "../lib/jwks";
|
|
3
|
+
import { getOidcConfig } from "../lib/appConfig";
|
|
4
|
+
export function createOidcRouter() {
|
|
5
|
+
const router = new Hono();
|
|
6
|
+
router.get("/.well-known/openid-configuration", (c) => {
|
|
7
|
+
const config = getOidcConfig();
|
|
8
|
+
if (!config)
|
|
9
|
+
return c.json({ error: "OIDC not configured" }, 404);
|
|
10
|
+
const issuer = config.issuer;
|
|
11
|
+
const tokenEndpoint = config.tokenEndpoint ?? `${issuer}/oauth/token`;
|
|
12
|
+
return c.json({
|
|
13
|
+
issuer,
|
|
14
|
+
authorization_endpoint: `${issuer}/auth/oauth`,
|
|
15
|
+
token_endpoint: tokenEndpoint,
|
|
16
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
17
|
+
response_types_supported: ["code"],
|
|
18
|
+
subject_types_supported: ["public"],
|
|
19
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
20
|
+
scopes_supported: config.scopes ?? ["openid"],
|
|
21
|
+
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
22
|
+
claims_supported: ["sub", "iss", "aud", "exp", "iat"],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
router.get("/.well-known/jwks.json", (c) => {
|
|
26
|
+
return c.json(getJwks());
|
|
27
|
+
});
|
|
28
|
+
return router;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const createPasskeyRouter: () => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|