@lastshotlabs/bunshot 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +322 -16
  2. package/dist/adapters/memoryAuth.d.ts +3 -0
  3. package/dist/adapters/memoryAuth.js +48 -2
  4. package/dist/adapters/mongoAuth.js +39 -1
  5. package/dist/adapters/sqliteAuth.d.ts +3 -0
  6. package/dist/adapters/sqliteAuth.js +53 -0
  7. package/dist/app.d.ts +45 -2
  8. package/dist/app.js +79 -4
  9. package/dist/index.d.ts +14 -7
  10. package/dist/index.js +8 -4
  11. package/dist/lib/appConfig.d.ts +35 -0
  12. package/dist/lib/appConfig.js +10 -0
  13. package/dist/lib/authAdapter.d.ts +24 -0
  14. package/dist/lib/authRateLimit.d.ts +2 -0
  15. package/dist/lib/authRateLimit.js +4 -0
  16. package/dist/lib/clientIp.d.ts +14 -0
  17. package/dist/lib/clientIp.js +52 -0
  18. package/dist/lib/constants.d.ts +2 -0
  19. package/dist/lib/constants.js +2 -0
  20. package/dist/lib/crypto.d.ts +11 -0
  21. package/dist/lib/crypto.js +22 -0
  22. package/dist/lib/emailVerification.d.ts +4 -0
  23. package/dist/lib/emailVerification.js +20 -12
  24. package/dist/lib/jwt.js +17 -4
  25. package/dist/lib/mfaChallenge.d.ts +23 -1
  26. package/dist/lib/mfaChallenge.js +151 -42
  27. package/dist/lib/oauth.d.ts +14 -1
  28. package/dist/lib/oauth.js +19 -1
  29. package/dist/lib/oauthCode.d.ts +15 -0
  30. package/dist/lib/oauthCode.js +90 -0
  31. package/dist/lib/resetPassword.js +12 -16
  32. package/dist/lib/session.js +6 -4
  33. package/dist/lib/ws.js +5 -1
  34. package/dist/middleware/bearerAuth.js +4 -3
  35. package/dist/middleware/botProtection.js +2 -2
  36. package/dist/middleware/cacheResponse.d.ts +1 -0
  37. package/dist/middleware/cacheResponse.js +14 -2
  38. package/dist/middleware/cors.d.ts +2 -0
  39. package/dist/middleware/cors.js +22 -8
  40. package/dist/middleware/csrf.d.ts +18 -0
  41. package/dist/middleware/csrf.js +115 -0
  42. package/dist/middleware/rateLimit.js +2 -3
  43. package/dist/models/AuthUser.d.ts +9 -0
  44. package/dist/models/AuthUser.js +9 -0
  45. package/dist/routes/auth.js +21 -9
  46. package/dist/routes/mfa.d.ts +5 -1
  47. package/dist/routes/mfa.js +221 -14
  48. package/dist/routes/oauth.js +274 -10
  49. package/dist/schemas/auth.d.ts +2 -0
  50. package/dist/schemas/auth.js +22 -1
  51. package/dist/server.d.ts +6 -0
  52. package/dist/server.js +10 -3
  53. package/dist/services/auth.d.ts +1 -0
  54. package/dist/services/auth.js +21 -5
  55. package/dist/services/mfa.d.ts +47 -0
  56. package/dist/services/mfa.js +276 -9
  57. package/dist/ws/index.js +3 -2
  58. package/docs/sections/auth-flow/full.md +180 -2
  59. package/docs/sections/configuration/full.md +20 -0
  60. package/docs/sections/configuration/overview.md +1 -1
  61. package/docs/sections/configuration-example/full.md +19 -1
  62. package/docs/sections/exports/full.md +11 -2
  63. package/docs/sections/multi-tenancy/full.md +5 -1
  64. package/docs/sections/oauth/full.md +80 -10
  65. package/docs/sections/oauth/overview.md +2 -2
  66. package/docs/sections/peer-dependencies/full.md +6 -2
  67. package/docs/sections/response-caching/full.md +3 -1
  68. package/docs/sections/websocket/full.md +4 -3
  69. package/docs/sections/websocket/overview.md +1 -1
  70. package/package.json +16 -4
@@ -0,0 +1,15 @@
1
+ type OAuthCodeStore = "redis" | "mongo" | "sqlite" | "memory";
2
+ export declare const setOAuthCodeStore: (store: OAuthCodeStore) => void;
3
+ export interface OAuthCodePayload {
4
+ token: string;
5
+ userId: string;
6
+ email?: string;
7
+ refreshToken?: string;
8
+ }
9
+ /** Store a one-time authorization code. Returns the raw code (for the redirect URL).
10
+ * Only the SHA-256 hash is persisted. */
11
+ export declare const storeOAuthCode: (payload: OAuthCodePayload) => Promise<string>;
12
+ /** Atomically consume an authorization code — returns its payload and deletes it.
13
+ * Returns null if invalid, expired, or already used. */
14
+ export declare const consumeOAuthCode: (code: string) => Promise<OAuthCodePayload | null>;
15
+ export {};
@@ -0,0 +1,90 @@
1
+ import { getRedis } from "./redis";
2
+ import { appConnection, mongoose } from "./mongo";
3
+ import { getAppName } from "./appConfig";
4
+ import { sha256 } from "./crypto";
5
+ import { memoryStoreOAuthCode, memoryConsumeOAuthCode, } from "../adapters/memoryAuth";
6
+ import { sqliteStoreOAuthCode, sqliteConsumeOAuthCode, } from "../adapters/sqliteAuth";
7
+ function getOAuthCodeModel() {
8
+ if (appConnection.models["OAuthCode"])
9
+ return appConnection.models["OAuthCode"];
10
+ const { Schema } = mongoose;
11
+ const schema = new Schema({
12
+ codeHash: { type: String, required: true, unique: true },
13
+ token: { type: String, required: true },
14
+ userId: { type: String, required: true },
15
+ email: { type: String },
16
+ refreshToken: { type: String },
17
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
18
+ }, { collection: "oauth_codes" });
19
+ return appConnection.model("OAuthCode", schema);
20
+ }
21
+ let _store = "redis";
22
+ export const setOAuthCodeStore = (store) => { _store = store; };
23
+ const CODE_TTL = 60; // 60 seconds
24
+ // ---------------------------------------------------------------------------
25
+ // Public API
26
+ // ---------------------------------------------------------------------------
27
+ /** Store a one-time authorization code. Returns the raw code (for the redirect URL).
28
+ * Only the SHA-256 hash is persisted. */
29
+ export const storeOAuthCode = async (payload) => {
30
+ const code = crypto.randomUUID();
31
+ const hash = sha256(code);
32
+ if (_store === "memory") {
33
+ memoryStoreOAuthCode(hash, payload, CODE_TTL);
34
+ return code;
35
+ }
36
+ if (_store === "sqlite") {
37
+ sqliteStoreOAuthCode(hash, payload, CODE_TTL);
38
+ return code;
39
+ }
40
+ if (_store === "mongo") {
41
+ await getOAuthCodeModel().create({
42
+ codeHash: hash,
43
+ ...payload,
44
+ expiresAt: new Date(Date.now() + CODE_TTL * 1000),
45
+ });
46
+ return code;
47
+ }
48
+ // Redis
49
+ await getRedis().set(`oauthcode:${getAppName()}:${hash}`, JSON.stringify(payload), "EX", CODE_TTL);
50
+ return code;
51
+ };
52
+ /** Atomically consume an authorization code — returns its payload and deletes it.
53
+ * Returns null if invalid, expired, or already used. */
54
+ export const consumeOAuthCode = async (code) => {
55
+ const hash = sha256(code);
56
+ if (_store === "memory")
57
+ return memoryConsumeOAuthCode(hash);
58
+ if (_store === "sqlite")
59
+ return sqliteConsumeOAuthCode(hash);
60
+ if (_store === "mongo") {
61
+ const doc = await getOAuthCodeModel()
62
+ .findOneAndDelete({ codeHash: hash, expiresAt: { $gt: new Date() } })
63
+ .lean();
64
+ if (!doc)
65
+ return null;
66
+ return { token: doc.token, userId: doc.userId, email: doc.email, refreshToken: doc.refreshToken };
67
+ }
68
+ // Redis
69
+ const key = `oauthcode:${getAppName()}:${hash}`;
70
+ const redis = getRedis();
71
+ let raw = null;
72
+ if (typeof redis.getdel === "function") {
73
+ try {
74
+ raw = await redis.getdel(key);
75
+ }
76
+ catch {
77
+ raw = await redis.get(key);
78
+ if (raw)
79
+ await redis.del(key);
80
+ }
81
+ }
82
+ else {
83
+ raw = await redis.get(key);
84
+ if (raw)
85
+ await redis.del(key);
86
+ }
87
+ if (!raw)
88
+ return null;
89
+ return JSON.parse(raw);
90
+ };
@@ -1,24 +1,20 @@
1
- import { createHash } from "crypto";
2
1
  import { getRedis } from "./redis";
3
- import { appConnection } from "./mongo";
2
+ import { appConnection, mongoose } from "./mongo";
4
3
  import { getAppName, getResetTokenExpiry } from "./appConfig";
5
- import { Schema } from "mongoose";
6
4
  import { sqliteCreateResetToken, sqliteConsumeResetToken, } from "../adapters/sqliteAuth";
7
5
  import { memoryCreateResetToken, memoryConsumeResetToken, } from "../adapters/memoryAuth";
8
- // ---------------------------------------------------------------------------
9
- // Token hashing — store SHA-256(token); raw token is only in the email link.
10
- // If the store is ever leaked, outstanding tokens cannot be replayed directly.
11
- // ---------------------------------------------------------------------------
12
- const hashToken = (token) => createHash("sha256").update(token).digest("hex");
13
- const resetSchema = new Schema({
14
- token: { type: String, required: true, unique: true },
15
- userId: { type: String, required: true },
16
- email: { type: String, required: true },
17
- expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
18
- }, { collection: "password_resets" });
6
+ import { sha256 as hashToken } from "./crypto";
19
7
  function getResetModel() {
20
- return appConnection.models["PasswordReset"] ??
21
- appConnection.model("PasswordReset", resetSchema);
8
+ if (appConnection.models["PasswordReset"])
9
+ return appConnection.models["PasswordReset"];
10
+ const { Schema } = mongoose;
11
+ const resetSchema = new Schema({
12
+ token: { type: String, required: true, unique: true },
13
+ userId: { type: String, required: true },
14
+ email: { type: String, required: true },
15
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
16
+ }, { collection: "password_resets" });
17
+ return appConnection.model("PasswordReset", resetSchema);
22
18
  }
23
19
  // ---------------------------------------------------------------------------
24
20
  // Redis helpers
@@ -16,11 +16,11 @@ function getSessionModel() {
16
16
  expiresAt: { type: Date, required: true },
17
17
  ipAddress: { type: String },
18
18
  userAgent: { type: String },
19
- refreshToken: { type: String, default: null, sparse: true },
19
+ refreshToken: { type: String, default: null },
20
20
  prevRefreshToken: { type: String, default: null },
21
21
  prevTokenExpiresAt: { type: Date, default: null },
22
22
  }, { collection: "sessions", timestamps: false });
23
- sessionSchema.index({ refreshToken: 1 }, { sparse: true, unique: true });
23
+ sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
24
24
  // Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
25
25
  // When persisting, token is nulled (soft-delete) but the row is kept indefinitely.
26
26
  if (!getPersistSessionMetadata()) {
@@ -234,9 +234,11 @@ async function redisRotateRefreshToken(sessionId, newRefreshToken, newAccessToke
234
234
  await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
235
235
  // Set new reverse-lookup with full refresh expiry
236
236
  await redis.set(redisRefreshTokenKey(newRefreshToken), sessionId, "EX", refreshExpiry);
237
- // Update old reverse-lookup to expire after grace window
237
+ // Update old reverse-lookup to expire after grace window.
238
+ // Use a minimum TTL of 60s so that theft detection still works when grace is 0.
238
239
  if (oldRefreshToken) {
239
- await redis.expire(redisRefreshTokenKey(oldRefreshToken), graceSeconds);
240
+ const oldKeyTtl = Math.max(graceSeconds, 60);
241
+ await redis.expire(redisRefreshTokenKey(oldRefreshToken), oldKeyTtl);
240
242
  }
241
243
  }
242
244
  // ---------------------------------------------------------------------------
package/dist/lib/ws.js CHANGED
@@ -11,9 +11,13 @@ const _roomRegistry = new Map();
11
11
  export const getRooms = () => [..._roomRegistry.keys()];
12
12
  /** Socket IDs subscribed to a given room */
13
13
  export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
14
+ const MAX_ROOM_ACTION_SIZE = 4096; // 4 KB — room actions are small JSON payloads
14
15
  export const handleRoomActions = async (ws, message, onSubscribe) => {
15
16
  try {
16
- const data = JSON.parse(typeof message === "string" ? message : Buffer.from(message).toString());
17
+ const raw = typeof message === "string" ? message : Buffer.from(message).toString();
18
+ if (raw.length > MAX_ROOM_ACTION_SIZE)
19
+ return false; // not a room action
20
+ const data = JSON.parse(raw);
17
21
  if (data.action === "subscribe" && typeof data.room === "string") {
18
22
  if (onSubscribe && !(await onSubscribe(ws, data.room))) {
19
23
  ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
@@ -1,9 +1,10 @@
1
- const isProd = process.env.NODE_ENV === "production";
2
- const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
1
+ import { timingSafeEqual } from "../lib/crypto";
3
2
  export const bearerAuth = async (c, next) => {
3
+ const isProd = process.env.NODE_ENV === "production";
4
+ const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
4
5
  const header = c.req.header("Authorization");
5
6
  const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
6
- if (!token || token !== validToken) {
7
+ if (!token || !validToken || !timingSafeEqual(token, validToken)) {
7
8
  return c.json({ error: "Unauthorized" }, 401);
8
9
  }
9
10
  await next();
@@ -1,3 +1,4 @@
1
+ import { getClientIp } from "../lib/clientIp";
1
2
  // ---------------------------------------------------------------------------
2
3
  // CIDR helpers (IPv4 only; IPv6 exact-match supported)
3
4
  // ---------------------------------------------------------------------------
@@ -40,8 +41,7 @@ export const botProtection = ({ blockList = [], }) => {
40
41
  if (blockList.length === 0)
41
42
  return (_c, next) => next();
42
43
  return async (c, next) => {
43
- const raw = c.req.header("x-forwarded-for") ?? "";
44
- const ip = raw.split(",")[0]?.trim() ?? "unknown";
44
+ const ip = getClientIp(c);
45
45
  if (ip !== "unknown" && isBlocked(ip, blockList)) {
46
46
  return c.json({ error: "Forbidden" }, 403);
47
47
  }
@@ -1,5 +1,6 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
2
  import type { AppEnv } from "../lib/context";
3
+ export declare function getCacheModel(): import("mongoose").Model<any, {}, {}, {}, any, any, any>;
3
4
  type CacheStore = "redis" | "mongo" | "sqlite" | "memory";
4
5
  export declare const setCacheStore: (store: CacheStore) => void;
5
6
  export declare const bustCache: (key: string) => Promise<void>;
@@ -3,7 +3,7 @@ import { getAppName } from "../lib/appConfig";
3
3
  import { appConnection, mongoose } from "../lib/mongo";
4
4
  import { isSqliteReady, sqliteGetCache, sqliteSetCache, sqliteDelCache, sqliteDelCachePattern } from "../adapters/sqliteAuth";
5
5
  import { memoryGetCache, memorySetCache, memoryDelCache, memoryDelCachePattern } from "../adapters/memoryAuth";
6
- function getCacheModel() {
6
+ export function getCacheModel() {
7
7
  if (appConnection.models["CacheEntry"])
8
8
  return appConnection.models["CacheEntry"];
9
9
  const { Schema } = mongoose;
@@ -130,6 +130,14 @@ export const bustCachePattern = async (pattern) => {
130
130
  const fullPattern = `cache:${getAppName()}:${pattern}`;
131
131
  await Promise.all([storeDelPattern("redis", fullPattern), storeDelPattern("mongo", fullPattern), storeDelPattern("sqlite", fullPattern), storeDelPattern("memory", fullPattern)]);
132
132
  };
133
+ /** Headers that must never be cached — storing these can cause session fixation or auth bypass. */
134
+ const UNCACHEABLE_HEADERS = new Set([
135
+ "set-cookie",
136
+ "www-authenticate",
137
+ "authorization",
138
+ "x-csrf-token",
139
+ "proxy-authenticate",
140
+ ]);
133
141
  export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
134
142
  return async (c, next) => {
135
143
  const appName = getAppName();
@@ -151,7 +159,11 @@ export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
151
159
  if (res.status >= 200 && res.status < 300) {
152
160
  const body = await res.text();
153
161
  const headers = {};
154
- res.headers.forEach((value, name) => { headers[name] = value; });
162
+ res.headers.forEach((value, name) => {
163
+ if (!UNCACHEABLE_HEADERS.has(name.toLowerCase())) {
164
+ headers[name] = value;
165
+ }
166
+ });
155
167
  await storeSet(store, cacheKey, JSON.stringify({ status: res.status, headers, body }), ttl);
156
168
  c.res = new Response(body, {
157
169
  status: res.status,
@@ -1,2 +1,4 @@
1
1
  import type { Middleware } from ".";
2
+ /** Configure the allowed CORS origins. Call once at startup. */
3
+ export declare const setCorsOrigins: (origins: string | string[]) => void;
2
4
  export declare const cors: Middleware;
@@ -1,17 +1,31 @@
1
+ let _allowedOrigins = "*";
2
+ /** Configure the allowed CORS origins. Call once at startup. */
3
+ export const setCorsOrigins = (origins) => { _allowedOrigins = origins; };
1
4
  export const cors = async (req, next) => {
5
+ const origin = req.headers.get("Origin");
6
+ const headers = corsHeaders(origin);
2
7
  if (req.method === "OPTIONS") {
3
- return new Response(null, { status: 204, headers: corsHeaders() });
8
+ return new Response(null, { status: 204, headers });
4
9
  }
5
10
  const res = await next(req);
6
- const headers = new Headers(res.headers);
7
- for (const [k, v] of Object.entries(corsHeaders()))
8
- headers.set(k, v);
9
- return new Response(res.body, { status: res.status, headers });
11
+ const resHeaders = new Headers(res.headers);
12
+ for (const [k, v] of Object.entries(headers))
13
+ resHeaders.set(k, v);
14
+ return new Response(res.body, { status: res.status, headers: resHeaders });
10
15
  };
11
- const corsHeaders = () => {
16
+ function corsHeaders(requestOrigin) {
17
+ let allowOrigin;
18
+ if (_allowedOrigins === "*") {
19
+ allowOrigin = "*";
20
+ }
21
+ else {
22
+ const origins = Array.isArray(_allowedOrigins) ? _allowedOrigins : [_allowedOrigins];
23
+ allowOrigin = requestOrigin && origins.includes(requestOrigin) ? requestOrigin : origins[0];
24
+ }
12
25
  return {
13
- "Access-Control-Allow-Origin": "*",
26
+ "Access-Control-Allow-Origin": allowOrigin,
14
27
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
15
28
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
29
+ ...(allowOrigin !== "*" ? { Vary: "Origin" } : {}),
16
30
  };
17
- };
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { setCookie, deleteCookie } from "hono/cookie";
3
+ import type { AppEnv } from "../lib/context";
4
+ export interface CsrfMiddlewareOptions {
5
+ exemptPaths?: string[];
6
+ checkOrigin?: boolean;
7
+ allowedOrigins?: string | string[];
8
+ }
9
+ /**
10
+ * Refreshes the CSRF token cookie — call on login/register to prevent
11
+ * session fixation-adjacent attacks.
12
+ */
13
+ export declare function refreshCsrfToken(c: Parameters<typeof setCookie>[0]): void;
14
+ /**
15
+ * Clears the CSRF token cookie — call on logout.
16
+ */
17
+ export declare function clearCsrfToken(c: Parameters<typeof deleteCookie>[0]): void;
18
+ export declare const csrfProtection: (options?: CsrfMiddlewareOptions) => MiddlewareHandler<AppEnv>;
@@ -0,0 +1,115 @@
1
+ import { getCookie, setCookie, deleteCookie } from "hono/cookie";
2
+ import { timingSafeEqual } from "../lib/crypto";
3
+ import { COOKIE_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "../lib/constants";
4
+ import { createHmac, randomBytes } from "crypto";
5
+ const isProd = process.env.NODE_ENV === "production";
6
+ const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
7
+ function getJwtSecret() {
8
+ const secret = isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV;
9
+ if (!secret)
10
+ throw new Error("CSRF middleware requires JWT_SECRET_DEV/JWT_SECRET_PROD to be set");
11
+ return secret;
12
+ }
13
+ function generateCsrfToken(secret) {
14
+ const token = randomBytes(32).toString("hex");
15
+ const sig = createHmac("sha256", secret).update(token).digest("hex");
16
+ return `${token}.${sig}`;
17
+ }
18
+ function verifyCsrfSignature(cookieValue, secret) {
19
+ const dotIdx = cookieValue.indexOf(".");
20
+ if (dotIdx === -1)
21
+ return false;
22
+ const token = cookieValue.substring(0, dotIdx);
23
+ const sig = cookieValue.substring(dotIdx + 1);
24
+ const expected = createHmac("sha256", secret).update(token).digest("hex");
25
+ return timingSafeEqual(sig, expected);
26
+ }
27
+ const csrfCookieOptions = {
28
+ httpOnly: false,
29
+ secure: isProd,
30
+ sameSite: "Lax",
31
+ path: "/",
32
+ maxAge: 60 * 60 * 24 * 365, // 1 year — tied to browser, not session
33
+ };
34
+ /**
35
+ * Refreshes the CSRF token cookie — call on login/register to prevent
36
+ * session fixation-adjacent attacks.
37
+ */
38
+ export function refreshCsrfToken(c) {
39
+ const secret = getJwtSecret();
40
+ const token = generateCsrfToken(secret);
41
+ setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
42
+ }
43
+ /**
44
+ * Clears the CSRF token cookie — call on logout.
45
+ */
46
+ export function clearCsrfToken(c) {
47
+ deleteCookie(c, COOKIE_CSRF_TOKEN, { path: "/" });
48
+ }
49
+ export const csrfProtection = (options = {}) => {
50
+ const { exemptPaths = [], checkOrigin = true, allowedOrigins } = options;
51
+ // Normalize allowed origins for origin validation
52
+ const originSet = new Set();
53
+ if (allowedOrigins) {
54
+ const origins = Array.isArray(allowedOrigins) ? allowedOrigins : [allowedOrigins];
55
+ for (const o of origins) {
56
+ if (o !== "*")
57
+ originSet.add(o.replace(/\/$/, ""));
58
+ }
59
+ }
60
+ return async (c, next) => {
61
+ const secret = getJwtSecret();
62
+ // Set CSRF cookie on every response if not already present
63
+ const existingCsrf = getCookie(c, COOKIE_CSRF_TOKEN);
64
+ if (!existingCsrf) {
65
+ const token = generateCsrfToken(secret);
66
+ setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
67
+ }
68
+ // Only validate state-changing methods
69
+ if (!STATE_CHANGING_METHODS.has(c.req.method)) {
70
+ return next();
71
+ }
72
+ // Skip if no auth cookie present — not vulnerable to CSRF
73
+ const authCookie = getCookie(c, COOKIE_TOKEN);
74
+ if (!authCookie) {
75
+ return next();
76
+ }
77
+ // Skip exempt paths
78
+ const path = c.req.path;
79
+ for (const exempt of exemptPaths) {
80
+ if (exempt.endsWith("*")) {
81
+ if (path.startsWith(exempt.slice(0, -1)))
82
+ return next();
83
+ }
84
+ else {
85
+ if (path === exempt)
86
+ return next();
87
+ }
88
+ }
89
+ // Origin validation (secondary layer)
90
+ if (checkOrigin && originSet.size > 0) {
91
+ const origin = c.req.header("origin");
92
+ if (origin) {
93
+ const normalized = origin.replace(/\/$/, "");
94
+ if (!originSet.has(normalized)) {
95
+ return c.json({ error: "CSRF origin mismatch" }, 403);
96
+ }
97
+ }
98
+ }
99
+ // Double submit cookie validation
100
+ const csrfCookie = getCookie(c, COOKIE_CSRF_TOKEN);
101
+ const csrfHeader = c.req.header(HEADER_CSRF_TOKEN);
102
+ if (!csrfCookie || !csrfHeader) {
103
+ return c.json({ error: "CSRF token missing" }, 403);
104
+ }
105
+ // Verify the cookie's HMAC signature (prevents cookie injection)
106
+ if (!verifyCsrfSignature(csrfCookie, secret)) {
107
+ return c.json({ error: "CSRF token invalid" }, 403);
108
+ }
109
+ // Compare header value to cookie value
110
+ if (!timingSafeEqual(csrfHeader, csrfCookie)) {
111
+ return c.json({ error: "CSRF token mismatch" }, 403);
112
+ }
113
+ return next();
114
+ };
115
+ };
@@ -1,11 +1,10 @@
1
1
  import { trackAttempt } from "../lib/authRateLimit";
2
2
  import { buildFingerprint } from "../lib/fingerprint";
3
+ import { getClientIp } from "../lib/clientIp";
3
4
  export const rateLimit = ({ windowMs, max, fingerprintLimit = false, }) => {
4
5
  const opts = { windowMs, max };
5
6
  return async (c, next) => {
6
- // Take the leftmost (client) IP from x-forwarded-for
7
- const raw = c.req.header("x-forwarded-for") ?? "";
8
- const ip = raw.split(",")[0]?.trim() || "unknown";
7
+ const ip = getClientIp(c);
9
8
  // Per-tenant namespacing: each tenant gets independent rate limit buckets
10
9
  const tenantId = c.get("tenantId");
11
10
  const prefix = tenantId ? `t:${tenantId}:` : "";
@@ -16,6 +16,15 @@ interface IAuthUser {
16
16
  recoveryCodes?: string[];
17
17
  /** MFA methods enabled for this user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
18
18
  mfaMethods?: string[];
19
+ /** WebAuthn credentials (security keys / platform authenticators). */
20
+ webauthnCredentials?: Array<{
21
+ credentialId: string;
22
+ publicKey: string;
23
+ signCount: number;
24
+ transports?: string[];
25
+ name?: string;
26
+ createdAt: Date;
27
+ }>;
19
28
  }
20
29
  type AuthUserDocument = IAuthUser & Document;
21
30
  export declare const AuthUser: Model<AuthUserDocument, {}, {}, {}, Document<unknown, {}, AuthUserDocument, {}, import("mongoose").DefaultSchemaOptions> & IAuthUser & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
@@ -22,6 +22,15 @@ function getAuthUser() {
22
22
  recoveryCodes: [{ type: String }],
23
23
  /** MFA methods enabled for this user. */
24
24
  mfaMethods: [{ type: String }],
25
+ /** WebAuthn credentials (security keys / platform authenticators). */
26
+ webauthnCredentials: [{
27
+ credentialId: { type: String, required: true },
28
+ publicKey: { type: String, required: true },
29
+ signCount: { type: Number, required: true, default: 0 },
30
+ transports: [{ type: String }],
31
+ name: { type: String },
32
+ createdAt: { type: Date, default: Date.now },
33
+ }],
25
34
  }, { timestamps: true });
26
35
  schema.index({ providerIds: 1 });
27
36
  _AuthUser = authConnection.model("AuthUser", schema);
@@ -2,7 +2,7 @@ import { createRoute, withSecurity } from "../lib/createRoute";
2
2
  import { z } from "zod";
3
3
  import { setCookie, getCookie, deleteCookie } from "hono/cookie";
4
4
  import * as AuthService from "../services/auth";
5
- import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
5
+ import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
6
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";
@@ -10,8 +10,10 @@ 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 { getRefreshTokenExpiry, getAccessTokenExpiry } from "../lib/appConfig";
13
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
14
+ import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
14
15
  import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
16
+ import { getClientIp } from "../lib/clientIp";
15
17
  const isProd = process.env.NODE_ENV === "production";
16
18
  const TokenResponse = z.object({
17
19
  token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
@@ -33,7 +35,6 @@ const cookieOptions = (maxAge) => ({
33
35
  path: "/",
34
36
  maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
35
37
  });
36
- const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
37
38
  export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
38
39
  const router = createRouter();
39
40
  const RegisterSchema = makeRegisterSchema(primaryField);
@@ -61,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
61
62
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
62
63
  },
63
64
  }), async (c) => {
64
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
65
+ const ip = getClientIp(c);
65
66
  if (await trackAttempt(`register:${ip}`, registerOpts)) {
66
67
  return c.json({ error: "Too many registration attempts. Try again later." }, 429);
67
68
  }
@@ -76,6 +77,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
76
77
  if (result.refreshToken) {
77
78
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
78
79
  }
80
+ if (getCsrfEnabled())
81
+ refreshCsrfToken(c);
79
82
  return c.json(result, 201);
80
83
  });
81
84
  router.openapi(createRoute({
@@ -99,7 +102,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
99
102
  return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
100
103
  }
101
104
  const metadata = {
102
- ipAddress: clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")),
105
+ ipAddress: getClientIp(c),
103
106
  userAgent: c.req.header("user-agent") ?? undefined,
104
107
  };
105
108
  try {
@@ -110,6 +113,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
110
113
  if (result.refreshToken) {
111
114
  setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
112
115
  }
116
+ if (getCsrfEnabled())
117
+ refreshCsrfToken(c);
113
118
  }
114
119
  return c.json(result, 200);
115
120
  }
@@ -255,6 +260,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
255
260
  await AuthService.logout(token);
256
261
  deleteCookie(c, COOKIE_TOKEN, { path: "/" });
257
262
  deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
263
+ if (getCsrfEnabled())
264
+ clearCsrfToken(c);
258
265
  return c.json({ message: "Logged out" }, 200);
259
266
  });
260
267
  // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
@@ -272,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
272
279
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
273
280
  },
274
281
  }), async (c) => {
275
- const ip = c.req.header("x-forwarded-for") ?? "unknown";
282
+ const ip = getClientIp(c);
276
283
  if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
277
284
  return c.json({ error: "Too many verification attempts. Try again later." }, 429);
278
285
  }
@@ -341,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
341
348
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
342
349
  },
343
350
  }), async (c) => {
344
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
351
+ const ip = getClientIp(c);
345
352
  const { email } = c.req.valid("json");
346
353
  // Rate-limit by both IP and email to prevent distributed email-bombing
347
354
  const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
@@ -379,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
379
386
  "application/json": {
380
387
  schema: z.object({
381
388
  token: z.string().describe("Single-use reset token received via email."),
382
- password: z.string().min(8).describe("New password. Minimum 8 characters."),
389
+ password: resetPasswordSchema().describe("New password."),
383
390
  }),
384
391
  },
385
392
  },
@@ -393,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
393
400
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
394
401
  },
395
402
  }), async (c) => {
396
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
403
+ const ip = getClientIp(c);
397
404
  if (await trackAttempt(`reset:${ip}`, resetOpts)) {
398
405
  return c.json({ error: "Too many attempts. Try again later." }, 429);
399
406
  }
@@ -444,8 +451,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
444
451
  responses: {
445
452
  200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
446
453
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
454
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
447
455
  },
448
456
  }), async (c) => {
457
+ const ip = getClientIp(c);
458
+ if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
459
+ return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
460
+ }
449
461
  const body = c.req.valid("json");
450
462
  const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
451
463
  if (!rt) {
@@ -1 +1,5 @@
1
- export declare const createMfaRouter: () => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
1
+ import type { AuthRateLimitConfig } from "../app";
2
+ export interface MfaRouterOptions {
3
+ rateLimit?: AuthRateLimitConfig;
4
+ }
5
+ export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;