@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
@@ -133,12 +133,50 @@ export const mongoAuthAdapter = {
133
133
  async setMfaMethods(userId, methods) {
134
134
  await AuthUser.findByIdAndUpdate(userId, { mfaMethods: methods });
135
135
  },
136
+ async getWebAuthnCredentials(userId) {
137
+ const user = await AuthUser.findById(userId, "webauthnCredentials").lean();
138
+ const creds = user?.webauthnCredentials ?? [];
139
+ return creds.map((c) => ({
140
+ credentialId: c.credentialId,
141
+ publicKey: c.publicKey,
142
+ signCount: c.signCount,
143
+ transports: c.transports,
144
+ name: c.name,
145
+ createdAt: c.createdAt instanceof Date ? c.createdAt.getTime() : c.createdAt,
146
+ }));
147
+ },
148
+ async addWebAuthnCredential(userId, credential) {
149
+ await AuthUser.findByIdAndUpdate(userId, {
150
+ $push: {
151
+ webauthnCredentials: {
152
+ credentialId: credential.credentialId,
153
+ publicKey: credential.publicKey,
154
+ signCount: credential.signCount,
155
+ transports: credential.transports,
156
+ name: credential.name,
157
+ createdAt: new Date(credential.createdAt),
158
+ },
159
+ },
160
+ });
161
+ },
162
+ async removeWebAuthnCredential(userId, credentialId) {
163
+ await AuthUser.findByIdAndUpdate(userId, {
164
+ $pull: { webauthnCredentials: { credentialId } },
165
+ });
166
+ },
167
+ async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
168
+ await AuthUser.findOneAndUpdate({ _id: userId, "webauthnCredentials.credentialId": credentialId }, { $set: { "webauthnCredentials.$.signCount": signCount } });
169
+ },
170
+ async findUserByWebAuthnCredentialId(credentialId) {
171
+ const user = await AuthUser.findOne({ "webauthnCredentials.credentialId": credentialId }, "_id").lean();
172
+ return user ? String(user._id) : null;
173
+ },
136
174
  async getTenantRoles(userId, tenantId) {
137
175
  const doc = await TenantRole.findOne({ userId, tenantId }, "roles").lean();
138
176
  return doc?.roles ?? [];
139
177
  },
140
178
  async setTenantRoles(userId, tenantId, roles) {
141
- await TenantRole.findOneAndUpdate({ userId, tenantId }, { roles }, { upsert: true });
179
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $set: { roles } }, { upsert: true });
142
180
  },
143
181
  async addTenantRole(userId, tenantId, role) {
144
182
  await TenantRole.findOneAndUpdate({ userId, tenantId }, { $addToSet: { roles: role } }, { upsert: true });
@@ -36,4 +36,7 @@ export declare const sqliteConsumeResetToken: (hash: string) => {
36
36
  userId: string;
37
37
  email: string;
38
38
  } | null;
39
+ import type { OAuthCodePayload } from "../lib/oauthCode";
40
+ export declare const sqliteStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
41
+ export declare const sqliteConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;
39
42
  export declare const startSqliteCleanup: (intervalMs?: number) => ReturnType<typeof setInterval>;
@@ -109,6 +109,24 @@ function initSchema(db) {
109
109
  PRIMARY KEY (userId, tenantId, role)
110
110
  )`);
111
111
  db.run("CREATE INDEX IF NOT EXISTS idx_tenant_roles_tenant ON tenant_roles(tenantId)");
112
+ db.run(`CREATE TABLE IF NOT EXISTS webauthn_credentials (
113
+ credentialId TEXT PRIMARY KEY,
114
+ userId TEXT NOT NULL,
115
+ publicKey TEXT NOT NULL,
116
+ signCount INTEGER NOT NULL DEFAULT 0,
117
+ transports TEXT NOT NULL DEFAULT '[]',
118
+ name TEXT,
119
+ createdAt INTEGER NOT NULL
120
+ )`);
121
+ db.run("CREATE INDEX IF NOT EXISTS idx_webauthn_userId ON webauthn_credentials(userId)");
122
+ db.run(`CREATE TABLE IF NOT EXISTS oauth_codes (
123
+ codeHash TEXT PRIMARY KEY,
124
+ token TEXT NOT NULL,
125
+ userId TEXT NOT NULL,
126
+ email TEXT,
127
+ refreshToken TEXT,
128
+ expiresAt INTEGER NOT NULL
129
+ )`);
112
130
  }
113
131
  // ---------------------------------------------------------------------------
114
132
  // Auth adapter
@@ -262,6 +280,30 @@ export const sqliteAuthAdapter = {
262
280
  async setMfaMethods(userId, methods) {
263
281
  getDb().run("UPDATE users SET mfaMethods = ? WHERE id = ?", [JSON.stringify(methods), userId]);
264
282
  },
283
+ async getWebAuthnCredentials(userId) {
284
+ const rows = getDb().query("SELECT credentialId, publicKey, signCount, transports, name, createdAt FROM webauthn_credentials WHERE userId = ?").all(userId);
285
+ return rows.map((r) => ({
286
+ credentialId: r.credentialId,
287
+ publicKey: r.publicKey,
288
+ signCount: r.signCount,
289
+ transports: JSON.parse(r.transports),
290
+ name: r.name ?? undefined,
291
+ createdAt: r.createdAt,
292
+ }));
293
+ },
294
+ async addWebAuthnCredential(userId, credential) {
295
+ getDb().run("INSERT INTO webauthn_credentials (credentialId, userId, publicKey, signCount, transports, name, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)", [credential.credentialId, userId, credential.publicKey, credential.signCount, JSON.stringify(credential.transports ?? []), credential.name ?? null, credential.createdAt]);
296
+ },
297
+ async removeWebAuthnCredential(userId, credentialId) {
298
+ getDb().run("DELETE FROM webauthn_credentials WHERE credentialId = ? AND userId = ?", [credentialId, userId]);
299
+ },
300
+ async updateWebAuthnCredentialSignCount(userId, credentialId, signCount) {
301
+ getDb().run("UPDATE webauthn_credentials SET signCount = ? WHERE credentialId = ? AND userId = ?", [signCount, credentialId, userId]);
302
+ },
303
+ async findUserByWebAuthnCredentialId(credentialId) {
304
+ const row = getDb().query("SELECT userId FROM webauthn_credentials WHERE credentialId = ?").get(credentialId);
305
+ return row?.userId ?? null;
306
+ },
265
307
  async getTenantRoles(userId, tenantId) {
266
308
  const rows = getDb().query("SELECT role FROM tenant_roles WHERE userId = ? AND tenantId = ?").all(userId, tenantId);
267
309
  return rows.map((r) => r.role);
@@ -433,6 +475,16 @@ export const sqliteConsumeResetToken = (hash) => {
433
475
  const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
434
476
  return row ?? null;
435
477
  };
478
+ export const sqliteStoreOAuthCode = (hash, payload, ttlSeconds) => {
479
+ const expiresAt = Date.now() + ttlSeconds * 1000;
480
+ getDb().run("INSERT INTO oauth_codes (codeHash, token, userId, email, refreshToken, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", [hash, payload.token, payload.userId, payload.email ?? null, payload.refreshToken ?? null, expiresAt]);
481
+ };
482
+ export const sqliteConsumeOAuthCode = (hash) => {
483
+ const row = getDb().query("DELETE FROM oauth_codes WHERE codeHash = ? AND expiresAt > ? RETURNING token, userId, email, refreshToken").get(hash, Date.now());
484
+ if (!row)
485
+ return null;
486
+ return { token: row.token, userId: row.userId, email: row.email ?? undefined, refreshToken: row.refreshToken ?? undefined };
487
+ };
436
488
  // ---------------------------------------------------------------------------
437
489
  // Optional periodic cleanup of expired rows
438
490
  // ---------------------------------------------------------------------------
@@ -451,5 +503,6 @@ export const startSqliteCleanup = (intervalMs = 3_600_000) => {
451
503
  db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
452
504
  db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
453
505
  db.run("DELETE FROM password_resets WHERE expiresAt <= ?", [now]);
506
+ db.run("DELETE FROM oauth_codes WHERE expiresAt <= ?", [now]);
454
507
  }, intervalMs);
455
508
  };
package/dist/app.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { MiddlewareHandler } from "hono";
3
3
  import type { AppEnv } from "./lib/context";
4
- import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig } from "./lib/appConfig";
4
+ import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, PasswordPolicyConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig, MfaWebAuthnConfig } from "./lib/appConfig";
5
5
  import type { AuthAdapter } from "./lib/authAdapter";
6
6
  import type { OAuthProviderConfig } from "./lib/oauth";
7
7
  type StoreType = "redis" | "mongo" | "sqlite" | "memory";
@@ -60,6 +60,9 @@ export interface OAuthConfig {
60
60
  providers?: OAuthProviderConfig;
61
61
  /** Where to redirect after a successful OAuth login. Defaults to "/" */
62
62
  postRedirect?: string;
63
+ /** Allowlist of redirect URLs. If set, the postRedirect URL is validated against this list.
64
+ * Relative paths (e.g., "/") are always allowed. Only absolute URLs are validated. */
65
+ allowedRedirectUrls?: string[];
63
66
  }
64
67
  export interface AuthRateLimitConfig {
65
68
  /** Max login failures per window before the account is locked. Default: 10 per 15 min. */
@@ -97,6 +100,16 @@ export interface AuthRateLimitConfig {
97
100
  windowMs?: number;
98
101
  max?: number;
99
102
  };
103
+ /** Max MFA verification attempts per IP per window. Default: 10 per 15 min. */
104
+ mfaVerify?: {
105
+ windowMs?: number;
106
+ max?: number;
107
+ };
108
+ /** Max MFA email OTP resend attempts per IP per window. Default: 5 per minute. */
109
+ mfaResend?: {
110
+ windowMs?: number;
111
+ max?: number;
112
+ };
100
113
  /**
101
114
  * Store backend for auth rate limit counters.
102
115
  * Defaults to "redis" when Redis is enabled, otherwise "memory".
@@ -137,6 +150,10 @@ export interface AuthConfig {
137
150
  * Mounts POST /auth/forgot-password and POST /auth/reset-password.
138
151
  */
139
152
  passwordReset?: PasswordResetConfig;
153
+ /** Password strength policy for registration and reset-password.
154
+ * Login is intentionally lenient (min 1) so users under older policies can still sign in.
155
+ * Defaults: minLength=8, requireLetter=true, requireDigit=true, requireSpecial=false. */
156
+ passwordPolicy?: PasswordPolicyConfig;
140
157
  /** Rate limit configuration for built-in auth endpoints. */
141
158
  rateLimit?: AuthRateLimitConfig;
142
159
  /** Session concurrency and metadata persistence policy. */
@@ -187,7 +204,7 @@ export interface AuthSessionPolicyConfig {
187
204
  */
188
205
  trackLastActive?: boolean;
189
206
  }
190
- export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig };
207
+ export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig, MfaWebAuthnConfig };
191
208
  export interface BotProtectionConfig {
192
209
  /**
193
210
  * List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.
@@ -203,9 +220,23 @@ export interface BotProtectionConfig {
203
220
  */
204
221
  fingerprintRateLimit?: boolean;
205
222
  }
223
+ export interface CsrfConfig {
224
+ /** Enable CSRF protection for cookie-authenticated state-changing requests. */
225
+ enabled: boolean;
226
+ /** Paths exempt from CSRF checks (in addition to built-in OAuth callback exemptions). Uses prefix matching when path ends with "*". */
227
+ exemptPaths?: string[];
228
+ /** Also validate Origin header against CORS origins. Default: true. */
229
+ checkOrigin?: boolean;
230
+ }
206
231
  export interface SecurityConfig {
207
232
  /** CORS origins. Defaults to "*" */
208
233
  cors?: string | string[];
234
+ /** Additional security headers to set via Hono's secureHeaders middleware.
235
+ * Pass a Content-Security-Policy, Permissions-Policy, etc. */
236
+ headers?: {
237
+ contentSecurityPolicy?: string;
238
+ permissionsPolicy?: string;
239
+ };
209
240
  /** Global rate limit. Defaults to 100 req / 60s */
210
241
  rateLimit?: {
211
242
  windowMs: number;
@@ -224,6 +255,18 @@ export interface SecurityConfig {
224
255
  * Runs before IP rate limiting so blocked IPs are rejected immediately.
225
256
  */
226
257
  botProtection?: BotProtectionConfig;
258
+ /**
259
+ * Trusted proxy configuration for IP extraction.
260
+ * - `false` (default): use socket-level IP only, ignore X-Forwarded-For entirely.
261
+ * - A number N: trust N proxy hops — take the Nth-from-right IP in the X-Forwarded-For chain.
262
+ */
263
+ trustProxy?: false | number;
264
+ /**
265
+ * CSRF protection for cookie-based auth. Opt-in.
266
+ * Uses signed double-submit cookie pattern with HMAC-SHA256.
267
+ * Only validates when the auth cookie is present on state-changing requests.
268
+ */
269
+ csrf?: CsrfConfig;
227
270
  }
228
271
  export interface ModelSchemasConfig {
229
272
  /**
package/dist/app.js CHANGED
@@ -7,8 +7,8 @@ import { HttpError } from "./lib/HttpError";
7
7
  import { rateLimit } from "./middleware/rateLimit";
8
8
  import { bearerAuth } from "./middleware/bearerAuth";
9
9
  import { identify } from "./middleware/identify";
10
- import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN } from "./lib/constants";
11
- import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig } from "./lib/appConfig";
10
+ import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN, HEADER_CSRF_TOKEN } from "./lib/constants";
11
+ import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setPasswordPolicy, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig, setCsrfEnabled } from "./lib/appConfig";
12
12
  import { setEmailVerificationStore } from "./lib/emailVerification";
13
13
  import { setPasswordResetStore } from "./lib/resetPassword";
14
14
  import { setAuthRateLimitStore } from "./lib/authRateLimit";
@@ -16,6 +16,7 @@ import { setAuthAdapter } from "./lib/authAdapter";
16
16
  import { mongoAuthAdapter } from "./adapters/mongoAuth";
17
17
  import { memoryAuthAdapter } from "./adapters/memoryAuth";
18
18
  import { initOAuthProviders, getConfiguredOAuthProviders, setOAuthStateStore } from "./lib/oauth";
19
+ import { setOAuthCodeStore } from "./lib/oauthCode";
19
20
  import { createOAuthRouter } from "./routes/oauth";
20
21
  import { connectMongo, connectAuthMongo, connectAppMongo } from "./lib/mongo";
21
22
  import { connectRedis } from "./lib/redis";
@@ -26,7 +27,13 @@ export const createApp = async (config) => {
26
27
  const { routesDir, app: appConfig = {}, auth: authConfig = {}, security: securityConfig = {}, middleware = [], db = {}, } = config;
27
28
  const appName = appConfig.name ?? "Bun Core API";
28
29
  const openApiVersion = appConfig.version ?? "1.0.0";
30
+ // Trust-proxy for IP extraction
31
+ const { setTrustProxy } = await import("./lib/clientIp");
32
+ setTrustProxy(securityConfig.trustProxy ?? false);
29
33
  const corsOrigins = securityConfig.cors ?? "*";
34
+ if (corsOrigins === "*" && process.env.NODE_ENV === "production") {
35
+ console.warn("[security] CORS is set to wildcard (*) in production. Configure security.cors with specific origins to restrict cross-origin access.");
36
+ }
30
37
  const rlConfig = securityConfig.rateLimit ?? { windowMs: 60_000, max: 100 };
31
38
  const botCfg = securityConfig.botProtection ?? {};
32
39
  const enableBearerAuth = securityConfig.bearerAuth !== false;
@@ -37,6 +44,29 @@ export const createApp = async (config) => {
37
44
  const explicitAuthAdapter = authConfig.adapter;
38
45
  const oauthProviders = authConfig.oauth?.providers;
39
46
  const postOAuthRedirect = authConfig.oauth?.postRedirect ?? "/";
47
+ const allowedRedirectUrls = authConfig.oauth?.allowedRedirectUrls;
48
+ // Validate postRedirect against allowlist at startup (not per-request)
49
+ if (allowedRedirectUrls && postOAuthRedirect !== "/") {
50
+ try {
51
+ const redirectUrl = new URL(postOAuthRedirect);
52
+ const allowed = allowedRedirectUrls.some((u) => {
53
+ try {
54
+ return new URL(u).origin === redirectUrl.origin;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ });
60
+ if (!allowed) {
61
+ throw new Error(`createApp: oauth.postRedirect "${postOAuthRedirect}" is not in the allowedRedirectUrls list. Add its origin to oauth.allowedRedirectUrls.`);
62
+ }
63
+ }
64
+ catch (e) {
65
+ if (e instanceof Error && e.message.startsWith("createApp:"))
66
+ throw e;
67
+ // Relative path — always allowed
68
+ }
69
+ }
40
70
  const roles = authConfig.roles ?? [];
41
71
  const defaultRole = authConfig.defaultRole;
42
72
  const primaryField = authConfig.primaryField ?? "email";
@@ -63,6 +93,7 @@ export const createApp = async (config) => {
63
93
  }
64
94
  setSessionStore(sessions);
65
95
  setOAuthStateStore(oauthState);
96
+ setOAuthCodeStore(oauthState);
66
97
  setCacheStore(cache);
67
98
  if (mongo === "single")
68
99
  await connectMongo();
@@ -104,6 +135,7 @@ export const createApp = async (config) => {
104
135
  setEmailVerificationConfig(emailVerification ?? null);
105
136
  setEmailVerificationStore(sessions);
106
137
  setPasswordResetConfig(passwordReset ?? null);
138
+ setPasswordPolicy(authConfig.passwordPolicy ?? {});
107
139
  setPasswordResetStore(sessions);
108
140
  setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
109
141
  setMaxSessions(sessionPolicy.maxSessions ?? 6);
@@ -126,8 +158,26 @@ export const createApp = async (config) => {
126
158
  const bearerAuthBypass = [...DEFAULT_BYPASS, ...oauthBypass, ...extraBypass];
127
159
  const app = new OpenAPIHono();
128
160
  app.use(logger());
161
+ const headerOpts = {};
162
+ if (securityConfig.headers?.contentSecurityPolicy) {
163
+ headerOpts["Content-Security-Policy"] = securityConfig.headers.contentSecurityPolicy;
164
+ }
165
+ if (securityConfig.headers?.permissionsPolicy) {
166
+ headerOpts["Permissions-Policy"] = securityConfig.headers.permissionsPolicy;
167
+ }
129
168
  app.use(secureHeaders());
130
- app.use(cors({ origin: corsOrigins, allowHeaders: ["Content-Type", "Authorization", HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN], exposeHeaders: ["x-cache"], credentials: true }));
169
+ if (Object.keys(headerOpts).length > 0) {
170
+ app.use(async (c, next) => {
171
+ await next();
172
+ for (const [k, v] of Object.entries(headerOpts)) {
173
+ c.res.headers.set(k, v);
174
+ }
175
+ });
176
+ }
177
+ const corsAllowHeaders = ["Content-Type", "Authorization", HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN];
178
+ if (securityConfig.csrf?.enabled)
179
+ corsAllowHeaders.push(HEADER_CSRF_TOKEN);
180
+ app.use(cors({ origin: corsOrigins, allowHeaders: corsAllowHeaders, exposeHeaders: ["x-cache"], credentials: true }));
131
181
  if ((botCfg.blockList?.length ?? 0) > 0) {
132
182
  const { botProtection } = await import("./middleware/botProtection");
133
183
  app.use(botProtection({ blockList: botCfg.blockList }));
@@ -143,8 +193,33 @@ export const createApp = async (config) => {
143
193
  });
144
194
  }
145
195
  app.use(identify);
196
+ // CSRF protection (after identify so we can check for auth cookie presence)
197
+ if (securityConfig.csrf?.enabled) {
198
+ setCsrfEnabled(true);
199
+ const { csrfProtection } = await import("./middleware/csrf");
200
+ const csrfExemptPaths = [
201
+ ...oauthBypass.filter(p => p.includes("/callback")),
202
+ ...(securityConfig.csrf.exemptPaths ?? []),
203
+ ];
204
+ app.use(csrfProtection({
205
+ exemptPaths: csrfExemptPaths,
206
+ checkOrigin: securityConfig.csrf.checkOrigin ?? true,
207
+ allowedOrigins: corsOrigins,
208
+ }));
209
+ }
146
210
  // Tenant resolution middleware (after identify, before user middleware + routes)
147
211
  if (config.tenancy) {
212
+ if (!config.tenancy.onResolve) {
213
+ if (process.env.NODE_ENV === "production") {
214
+ throw new Error("[security] Tenancy is configured without an onResolve callback. " +
215
+ "In production, onResolve is required to validate tenant IDs and prevent cross-tenant access. " +
216
+ "Provide tenancy.onResolve or remove the tenancy config.");
217
+ }
218
+ else {
219
+ console.warn("[security] Tenancy is configured without an onResolve callback — " +
220
+ "tenant IDs will be trusted without validation. This is unsafe in production.");
221
+ }
222
+ }
148
223
  const { createTenantMiddleware } = await import("./middleware/tenant");
149
224
  app.use(createTenantMiddleware(config.tenancy));
150
225
  }
@@ -218,7 +293,7 @@ export const createApp = async (config) => {
218
293
  setMfaChallengeSqliteDb(getDb());
219
294
  }
220
295
  const { createMfaRouter } = await import(`${coreRoutesDir}/mfa`);
221
- app.route("/", createMfaRouter());
296
+ app.route("/", createMfaRouter({ rateLimit: authRateLimit }));
222
297
  }
223
298
  if (config.jobs?.statusEndpoint) {
224
299
  const { createJobsRouter } = await import(`${coreRoutesDir}/jobs`);
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  export { createApp } from "./app";
2
2
  export { createServer } from "./server";
3
- export type { CreateAppConfig, ModelSchemasConfig, DbConfig, AppMeta, AuthConfig, AuthRateLimitConfig, AccountDeletionConfig, OAuthConfig, SecurityConfig, BotProtectionConfig, PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig, JobsConfig, TenancyConfig, TenantConfig } from "./app";
3
+ export type { CreateAppConfig, ModelSchemasConfig, DbConfig, AppMeta, AuthConfig, AuthRateLimitConfig, AccountDeletionConfig, OAuthConfig, SecurityConfig, CsrfConfig, BotProtectionConfig, PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig, MfaWebAuthnConfig, JobsConfig, TenancyConfig, TenantConfig } from "./app";
4
+ export type { PasswordPolicyConfig } from "./lib/appConfig";
4
5
  export type { CreateServerConfig, WsConfig } from "./server";
5
6
  export { appConnection, authConnection, mongoose, connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo } from "./lib/mongo";
6
7
  export { connectRedis, disconnectRedis, getRedis } from "./lib/redis";
7
8
  export { getAppRoles } from "./lib/appConfig";
8
9
  export { HttpError } from "./lib/HttpError";
9
- export { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "./lib/constants";
10
+ export { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "./lib/constants";
10
11
  export { createRouter } from "./lib/context";
11
12
  export { createRoute, withSecurity, registerSchema, registerSchemas } from "./lib/createRoute";
12
13
  export { zodToMongoose } from "./lib/zodToMongoose";
@@ -17,12 +18,16 @@ export type { AppEnv, AppVariables } from "./lib/context";
17
18
  export { signToken, verifyToken } from "./lib/jwt";
18
19
  export { log } from "./lib/logger";
19
20
  export { createResetToken, consumeResetToken, setPasswordResetStore } from "./lib/resetPassword";
21
+ export { timingSafeEqual, sha256 } from "./lib/crypto";
22
+ export { getClientIp, setTrustProxy } from "./lib/clientIp";
23
+ export { storeOAuthCode, consumeOAuthCode, setOAuthCodeStore } from "./lib/oauthCode";
24
+ export type { OAuthCodePayload } from "./lib/oauthCode";
20
25
  export { createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount, evictOldestSession, updateSessionLastActive, setSessionStore, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "./lib/session";
21
26
  export type { SessionMetadata, SessionInfo, RefreshResult } from "./lib/session";
22
27
  export { createVerificationToken, getVerificationToken, deleteVerificationToken } from "./lib/emailVerification";
23
- export { createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore } from "./lib/mfaChallenge";
24
- export type { MfaChallengeData } from "./lib/mfaChallenge";
25
- export { bustAuthLimit, trackAttempt, isLimited } from "./lib/authRateLimit";
28
+ export { createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore, createWebAuthnRegistrationChallenge, consumeWebAuthnRegistrationChallenge, clearMemoryMfaChallenges } from "./lib/mfaChallenge";
29
+ export type { MfaChallengeData, MfaChallengeOptions, MfaChallengePurpose } from "./lib/mfaChallenge";
30
+ export { bustAuthLimit, trackAttempt, isLimited, clearMemoryRateLimitStore } from "./lib/authRateLimit";
26
31
  export type { LimitOpts } from "./lib/authRateLimit";
27
32
  export { validate } from "./lib/validate";
28
33
  export { bearerAuth } from "./middleware/bearerAuth";
@@ -34,12 +39,14 @@ export type { RateLimitOptions } from "./middleware/rateLimit";
34
39
  export { userAuth } from "./middleware/userAuth";
35
40
  export { requireRole } from "./middleware/requireRole";
36
41
  export { requireVerifiedEmail } from "./middleware/requireVerifiedEmail";
37
- export { cacheResponse, bustCache, bustCachePattern, setCacheStore } from "./middleware/cacheResponse";
42
+ export { csrfProtection, refreshCsrfToken, clearCsrfToken } from "./middleware/csrf";
43
+ export type { CsrfMiddlewareOptions } from "./middleware/csrf";
44
+ export { cacheResponse, bustCache, bustCachePattern, setCacheStore, getCacheModel } from "./middleware/cacheResponse";
38
45
  export { buildFingerprint } from "./lib/fingerprint";
39
46
  export { sqliteAuthAdapter, setSqliteDb, startSqliteCleanup } from "./adapters/sqliteAuth";
40
47
  export { memoryAuthAdapter, clearMemoryStore } from "./adapters/memoryAuth";
41
48
  export { setUserRoles, addUserRole, removeUserRole, getTenantRoles, setTenantRoles, addTenantRole, removeTenantRole } from "./lib/roles";
42
- export type { AuthAdapter, OAuthProfile } from "./lib/authAdapter";
49
+ export type { AuthAdapter, OAuthProfile, WebAuthnCredential } from "./lib/authAdapter";
43
50
  export type { OAuthProviderConfig } from "./lib/oauth";
44
51
  export { websocket, createWsUpgradeHandler } from "./ws/index";
45
52
  export type { SocketData } from "./ws/index";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { connectRedis, disconnectRedis, getRedis } from "./lib/redis";
7
7
  // Lib utilities
8
8
  export { getAppRoles } from "./lib/appConfig";
9
9
  export { HttpError } from "./lib/HttpError";
10
- export { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "./lib/constants";
10
+ export { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "./lib/constants";
11
11
  export { createRouter } from "./lib/context";
12
12
  export { createRoute, withSecurity, registerSchema, registerSchemas } from "./lib/createRoute";
13
13
  export { zodToMongoose } from "./lib/zodToMongoose";
@@ -15,10 +15,13 @@ export { createDtoMapper } from "./lib/createDtoMapper";
15
15
  export { signToken, verifyToken } from "./lib/jwt";
16
16
  export { log } from "./lib/logger";
17
17
  export { createResetToken, consumeResetToken, setPasswordResetStore } from "./lib/resetPassword";
18
+ export { timingSafeEqual, sha256 } from "./lib/crypto";
19
+ export { getClientIp, setTrustProxy } from "./lib/clientIp";
20
+ export { storeOAuthCode, consumeOAuthCode, setOAuthCodeStore } from "./lib/oauthCode";
18
21
  export { createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount, evictOldestSession, updateSessionLastActive, setSessionStore, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "./lib/session";
19
22
  export { createVerificationToken, getVerificationToken, deleteVerificationToken } from "./lib/emailVerification";
20
- export { createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore } from "./lib/mfaChallenge";
21
- export { bustAuthLimit, trackAttempt, isLimited } from "./lib/authRateLimit";
23
+ export { createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore, createWebAuthnRegistrationChallenge, consumeWebAuthnRegistrationChallenge, clearMemoryMfaChallenges } from "./lib/mfaChallenge";
24
+ export { bustAuthLimit, trackAttempt, isLimited, clearMemoryRateLimitStore } from "./lib/authRateLimit";
22
25
  export { validate } from "./lib/validate";
23
26
  // Middleware
24
27
  export { bearerAuth } from "./middleware/bearerAuth";
@@ -28,7 +31,8 @@ export { rateLimit } from "./middleware/rateLimit";
28
31
  export { userAuth } from "./middleware/userAuth";
29
32
  export { requireRole } from "./middleware/requireRole";
30
33
  export { requireVerifiedEmail } from "./middleware/requireVerifiedEmail";
31
- export { cacheResponse, bustCache, bustCachePattern, setCacheStore } from "./middleware/cacheResponse";
34
+ export { csrfProtection, refreshCsrfToken, clearCsrfToken } from "./middleware/csrf";
35
+ export { cacheResponse, bustCache, bustCachePattern, setCacheStore, getCacheModel } from "./middleware/cacheResponse";
32
36
  // Lib utilities (bot protection)
33
37
  export { buildFingerprint } from "./lib/fingerprint";
34
38
  // Models
@@ -13,6 +13,16 @@ export interface PasswordResetConfig {
13
13
  /** Called with the user's email and the reset token. Use to send the reset email. */
14
14
  onSend: (email: string, token: string) => Promise<void>;
15
15
  }
16
+ export interface PasswordPolicyConfig {
17
+ /** Minimum password length. Defaults to 8. */
18
+ minLength?: number;
19
+ /** Require at least one letter (a–z or A–Z). Defaults to true. */
20
+ requireLetter?: boolean;
21
+ /** Require at least one digit (0–9). Defaults to true. */
22
+ requireDigit?: boolean;
23
+ /** Require at least one special character. Defaults to false. */
24
+ requireSpecial?: boolean;
25
+ }
16
26
  export declare const setAppName: (name: string) => void;
17
27
  export declare const getAppName: () => string;
18
28
  export declare const setAppRoles: (roles: string[]) => void;
@@ -26,6 +36,8 @@ export declare const getEmailVerificationConfig: () => EmailVerificationConfig |
26
36
  export declare const getTokenExpiry: () => number;
27
37
  export declare const setPasswordResetConfig: (config: PasswordResetConfig | null) => void;
28
38
  export declare const getPasswordResetConfig: () => PasswordResetConfig | null;
39
+ export declare const setPasswordPolicy: (config: PasswordPolicyConfig) => void;
40
+ export declare const getPasswordPolicy: () => PasswordPolicyConfig;
29
41
  export declare const getResetTokenExpiry: () => number;
30
42
  export declare const setMaxSessions: (n: number) => void;
31
43
  export declare const getMaxSessions: () => number;
@@ -55,6 +67,24 @@ export interface MfaEmailOtpConfig {
55
67
  /** OTP code length. Default: 6. */
56
68
  codeLength?: number;
57
69
  }
70
+ export interface MfaWebAuthnConfig {
71
+ /** Relying Party ID — typically the domain (e.g. "example.com"). Required. */
72
+ rpId: string;
73
+ /** Relying Party name shown in browser prompts. Defaults to app name. */
74
+ rpName?: string;
75
+ /** Expected origin(s) — full origin URL(s) like "https://example.com". Required. */
76
+ origin: string | string[];
77
+ /** Supported attestation conveyance preference. Default: "none". */
78
+ attestationType?: "none" | "direct" | "enterprise";
79
+ /** Authenticator attachment preference. Default: undefined (allows both platform + cross-platform). */
80
+ authenticatorAttachment?: "platform" | "cross-platform";
81
+ /** User verification requirement. Default: "preferred". */
82
+ userVerification?: "required" | "preferred" | "discouraged";
83
+ /** Timeout for ceremonies in milliseconds. Default: 60000 (60s). */
84
+ timeout?: number;
85
+ /** Reject authentication when sign count goes backward (cloned key detection). Default: false (accept + warn). */
86
+ strictSignCount?: boolean;
87
+ }
58
88
  export interface MfaConfig {
59
89
  /** Issuer name shown in authenticator apps. Defaults to app name. */
60
90
  issuer?: string;
@@ -70,6 +100,8 @@ export interface MfaConfig {
70
100
  challengeTtlSeconds?: number;
71
101
  /** Email OTP configuration. When set, enables email-based MFA as an option. */
72
102
  emailOtp?: MfaEmailOtpConfig;
103
+ /** WebAuthn/FIDO2 configuration. When set, enables security key MFA routes. */
104
+ webauthn?: MfaWebAuthnConfig;
73
105
  }
74
106
  export declare const setMfaConfig: (config: MfaConfig | null) => void;
75
107
  export declare const getMfaConfig: () => MfaConfig | null;
@@ -81,3 +113,6 @@ export declare const getMfaRecoveryCodeCount: () => number;
81
113
  export declare const getMfaChallengeTtl: () => number;
82
114
  export declare const getMfaEmailOtpConfig: () => MfaEmailOtpConfig | null;
83
115
  export declare const getMfaEmailOtpCodeLength: () => number;
116
+ export declare const getMfaWebAuthnConfig: () => MfaWebAuthnConfig | null;
117
+ export declare const setCsrfEnabled: (v: boolean) => void;
118
+ export declare const getCsrfEnabled: () => boolean;
@@ -4,6 +4,7 @@ let defaultRole = null;
4
4
  let _primaryField = "email";
5
5
  let _emailVerificationConfig = null;
6
6
  let _passwordResetConfig = null;
7
+ let _passwordPolicy = {};
7
8
  export const setAppName = (name) => { appName = name; };
8
9
  export const getAppName = () => appName;
9
10
  export const setAppRoles = (roles) => { appRoles = roles; };
@@ -18,6 +19,8 @@ const DEFAULT_TOKEN_EXPIRY = 60 * 60 * 24; // 24 hours
18
19
  export const getTokenExpiry = () => _emailVerificationConfig?.tokenExpiry ?? DEFAULT_TOKEN_EXPIRY;
19
20
  export const setPasswordResetConfig = (config) => { _passwordResetConfig = config; };
20
21
  export const getPasswordResetConfig = () => _passwordResetConfig;
22
+ export const setPasswordPolicy = (config) => { _passwordPolicy = config; };
23
+ export const getPasswordPolicy = () => _passwordPolicy;
21
24
  const DEFAULT_RESET_TOKEN_EXPIRY = 60 * 60; // 1 hour
22
25
  export const getResetTokenExpiry = () => _passwordResetConfig?.tokenExpiry ?? DEFAULT_RESET_TOKEN_EXPIRY;
23
26
  // ---------------------------------------------------------------------------
@@ -55,3 +58,10 @@ export const getMfaRecoveryCodeCount = () => _mfaConfig?.recoveryCodes ?? 10;
55
58
  export const getMfaChallengeTtl = () => _mfaConfig?.challengeTtlSeconds ?? 300;
56
59
  export const getMfaEmailOtpConfig = () => _mfaConfig?.emailOtp ?? null;
57
60
  export const getMfaEmailOtpCodeLength = () => _mfaConfig?.emailOtp?.codeLength ?? 6;
61
+ export const getMfaWebAuthnConfig = () => _mfaConfig?.webauthn ?? null;
62
+ // ---------------------------------------------------------------------------
63
+ // CSRF config
64
+ // ---------------------------------------------------------------------------
65
+ let _csrfEnabled = false;
66
+ export const setCsrfEnabled = (v) => { _csrfEnabled = v; };
67
+ export const getCsrfEnabled = () => _csrfEnabled;
@@ -3,6 +3,20 @@ export interface OAuthProfile {
3
3
  name?: string;
4
4
  avatarUrl?: string;
5
5
  }
6
+ export interface WebAuthnCredential {
7
+ /** Base64url-encoded credential ID. */
8
+ credentialId: string;
9
+ /** Base64url-encoded public key. */
10
+ publicKey: string;
11
+ /** Counter for signature verification (replay protection). */
12
+ signCount: number;
13
+ /** Transport hints from the authenticator (usb, ble, nfc, internal). */
14
+ transports?: string[];
15
+ /** User-assigned name for the key (e.g. "YubiKey 5"). */
16
+ name?: string;
17
+ /** When the credential was registered (epoch ms). */
18
+ createdAt: number;
19
+ }
6
20
  export interface AuthAdapter {
7
21
  findByEmail(email: string): Promise<{
8
22
  id: string;
@@ -78,6 +92,16 @@ export interface AuthAdapter {
78
92
  addTenantRole?(userId: string, tenantId: string, role: string): Promise<void>;
79
93
  /** Optional. Remove a single role from a user within a specific tenant. */
80
94
  removeTenantRole?(userId: string, tenantId: string, role: string): Promise<void>;
95
+ /** Optional. Get all WebAuthn credentials for a user. */
96
+ getWebAuthnCredentials?(userId: string): Promise<WebAuthnCredential[]>;
97
+ /** Optional. Add a WebAuthn credential for a user. */
98
+ addWebAuthnCredential?(userId: string, credential: WebAuthnCredential): Promise<void>;
99
+ /** Optional. Remove a WebAuthn credential by its credential ID. */
100
+ removeWebAuthnCredential?(userId: string, credentialId: string): Promise<void>;
101
+ /** Optional. Update the sign count for a WebAuthn credential after successful authentication. */
102
+ updateWebAuthnCredentialSignCount?(userId: string, credentialId: string, signCount: number): Promise<void>;
103
+ /** Optional. Find the user who owns a WebAuthn credential. Returns userId or null. Used for cross-user uniqueness checks. */
104
+ findUserByWebAuthnCredentialId?(credentialId: string): Promise<string | null>;
81
105
  }
82
106
  export declare const setAuthAdapter: (adapter: AuthAdapter) => void;
83
107
  export declare const getAuthAdapter: () => AuthAdapter;
@@ -9,3 +9,5 @@ export declare const isLimited: (key: string, opts: LimitOpts) => Promise<boolea
9
9
  export declare const trackAttempt: (key: string, opts: LimitOpts) => Promise<boolean>;
10
10
  /** Resets a rate limit key. Use on login success or for admin unlock. */
11
11
  export declare const bustAuthLimit: (key: string) => Promise<void>;
12
+ /** Clears all in-memory rate limit entries. Called by clearMemoryStore(). */
13
+ export declare const clearMemoryRateLimitStore: () => void;
@@ -75,3 +75,7 @@ export const trackAttempt = async (key, opts) => {
75
75
  export const bustAuthLimit = async (key) => {
76
76
  await _store.delete(key);
77
77
  };
78
+ /** Clears all in-memory rate limit entries. Called by clearMemoryStore(). */
79
+ export const clearMemoryRateLimitStore = () => {
80
+ _memoryStore.clear();
81
+ };
@@ -0,0 +1,14 @@
1
+ import type { Context } from "hono";
2
+ export declare const setTrustProxy: (value: false | number) => void;
3
+ /**
4
+ * Returns the client IP address, respecting the `trustProxy` setting.
5
+ *
6
+ * - When `trustProxy` is `false`: returns the socket-level IP (via Bun's
7
+ * `server.requestIP()`), ignoring `X-Forwarded-For` entirely.
8
+ * - When `trustProxy` is a number N: takes the Nth-from-right entry in the
9
+ * `X-Forwarded-For` chain (skipping N trusted proxy hops), falling back to
10
+ * the socket-level IP.
11
+ *
12
+ * Returns `"unknown"` if no IP can be determined.
13
+ */
14
+ export declare const getClientIp: (c: Context<any>) => string;