@lastshotlabs/bunshot 0.0.16 → 0.0.19

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 (72) 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/lib/zodToMongoose.d.ts +2 -2
  35. package/dist/lib/zodToMongoose.js +7 -3
  36. package/dist/middleware/bearerAuth.js +4 -3
  37. package/dist/middleware/botProtection.js +2 -2
  38. package/dist/middleware/cacheResponse.d.ts +1 -0
  39. package/dist/middleware/cacheResponse.js +14 -2
  40. package/dist/middleware/cors.d.ts +2 -0
  41. package/dist/middleware/cors.js +22 -8
  42. package/dist/middleware/csrf.d.ts +18 -0
  43. package/dist/middleware/csrf.js +115 -0
  44. package/dist/middleware/rateLimit.js +2 -3
  45. package/dist/models/AuthUser.d.ts +9 -0
  46. package/dist/models/AuthUser.js +9 -0
  47. package/dist/routes/auth.js +21 -9
  48. package/dist/routes/mfa.d.ts +5 -1
  49. package/dist/routes/mfa.js +221 -14
  50. package/dist/routes/oauth.js +274 -10
  51. package/dist/schemas/auth.d.ts +2 -0
  52. package/dist/schemas/auth.js +22 -1
  53. package/dist/server.d.ts +6 -0
  54. package/dist/server.js +10 -3
  55. package/dist/services/auth.d.ts +1 -0
  56. package/dist/services/auth.js +21 -5
  57. package/dist/services/mfa.d.ts +47 -0
  58. package/dist/services/mfa.js +276 -9
  59. package/dist/ws/index.js +3 -2
  60. package/docs/sections/auth-flow/full.md +180 -2
  61. package/docs/sections/configuration/full.md +20 -0
  62. package/docs/sections/configuration/overview.md +1 -1
  63. package/docs/sections/configuration-example/full.md +19 -1
  64. package/docs/sections/exports/full.md +11 -2
  65. package/docs/sections/multi-tenancy/full.md +5 -1
  66. package/docs/sections/oauth/full.md +80 -10
  67. package/docs/sections/oauth/overview.md +2 -2
  68. package/docs/sections/peer-dependencies/full.md +6 -2
  69. package/docs/sections/response-caching/full.md +3 -1
  70. package/docs/sections/websocket/full.md +4 -3
  71. package/docs/sections/websocket/overview.md +1 -1
  72. 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,4 +1,4 @@
1
- import { Schema } from "mongoose";
1
+ import type { Schema as SchemaType } from "mongoose";
2
2
  type ZodSchema = any;
3
3
  export type ZodToMongooseRefConfig = {
4
4
  /** DB field name (e.g., "account") */
@@ -14,7 +14,7 @@ export type ZodToMongooseConfig = {
14
14
  /** Override Mongoose type for specific fields (e.g., { date: { type: Date, required: true } }) */
15
15
  typeOverrides?: Record<string, unknown>;
16
16
  /** Subdocument array fields: { items: mongooseSubSchema } */
17
- subdocSchemas?: Record<string, Schema>;
17
+ subdocSchemas?: Record<string, SchemaType>;
18
18
  };
19
19
  /**
20
20
  * Derive a Mongoose SchemaDefinition from a Zod object schema.
@@ -1,4 +1,4 @@
1
- import { Schema } from "mongoose";
1
+ import { mongoose } from "./mongo";
2
2
  /** Unwrap nullable, optional, and default wrappers to get the core Zod type */
3
3
  function unwrap(zodType) {
4
4
  let t = zodType;
@@ -22,6 +22,10 @@ function unwrap(zodType) {
22
22
  }
23
23
  return { core: t, required };
24
24
  }
25
+ /** Lazily access the Mongoose Schema class (avoids top-level require of mongoose) */
26
+ function getSchema() {
27
+ return mongoose.Schema;
28
+ }
25
29
  /** Convert a single Zod type to a Mongoose field definition */
26
30
  function toMongooseField(zodType) {
27
31
  const { core, required } = unwrap(zodType);
@@ -36,7 +40,7 @@ function toMongooseField(zodType) {
36
40
  return { type: Date, required };
37
41
  if (defType === "enum")
38
42
  return { type: String, enum: core.options, required };
39
- return { type: Schema.Types.Mixed, required };
43
+ return { type: getSchema().Types.Mixed, required };
40
44
  }
41
45
  /**
42
46
  * Derive a Mongoose SchemaDefinition from a Zod object schema.
@@ -64,7 +68,7 @@ export function zodToMongoose(zodSchema, config = {}) {
64
68
  continue;
65
69
  if (config.refs?.[apiField]) {
66
70
  const { dbField, ref } = config.refs[apiField];
67
- fields[dbField] = { type: Schema.Types.ObjectId, ref, required: true };
71
+ fields[dbField] = { type: getSchema().Types.ObjectId, ref, required: true };
68
72
  continue;
69
73
  }
70
74
  if (config.typeOverrides?.[apiField]) {
@@ -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);