@lastshotlabs/bunshot 0.0.21 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -0,0 +1,388 @@
1
+ ### Auth Security Examples
2
+
3
+ Five reference configurations covering common security postures. Each is a real `createServer` call you can adapt — not a checklist of options to mix and match blindly, since the right combination depends on what you're building.
4
+
5
+ ---
6
+
7
+ #### Fintech / Healthcare — maximum security
8
+
9
+ Strict everything. MFA is mandatory for every user, sessions are short, passwords must be strong, and the app defends against distributed brute-force from day one.
10
+
11
+ ```ts
12
+ await createServer({
13
+ auth: {
14
+ passwordPolicy: {
15
+ minLength: 12,
16
+ requireLetter: true,
17
+ requireDigit: true,
18
+ requireSpecial: true, // forces non-alphanumeric character
19
+ },
20
+ mfa: {
21
+ issuer: "FinApp",
22
+ required: true, // 403 MFA_SETUP_REQUIRED until user enrolls
23
+ recoveryCodes: 10,
24
+ challengeTtlSeconds: 180, // 3-minute window to complete MFA
25
+ },
26
+ emailVerification: {
27
+ required: true, // can't log in until email is verified
28
+ onSend: async (email, token) => {
29
+ await mailer.send(email, `Verify your email: https://app.com/verify?t=${token}`);
30
+ },
31
+ tokenExpiry: 3600, // 1-hour window to verify (not 24h)
32
+ },
33
+ passwordReset: {
34
+ tokenExpiry: 1800, // 30-minute reset window (tighter than default 1h)
35
+ onSend: async (email, token) => {
36
+ await mailer.send(email, `Reset: https://app.com/reset?t=${token}`);
37
+ },
38
+ },
39
+ refreshTokens: {
40
+ accessTokenExpiry: 900, // 15-minute access tokens
41
+ refreshTokenExpiry: 604_800, // 7-day refresh (not 30 — re-auth weekly)
42
+ rotationGraceSeconds: 30,
43
+ },
44
+ sessionPolicy: {
45
+ maxSessions: 3, // limit concurrent devices
46
+ trackLastActive: true,
47
+ persistSessionMetadata: true,
48
+ includeInactiveSessions: true, // full device history visible to user
49
+ },
50
+ rateLimit: {
51
+ login: { windowMs: 15 * 60 * 1000, max: 5 }, // 5 failures / 15 min
52
+ register: { windowMs: 60 * 60 * 1000, max: 3 }, // 3 signups / hour per IP
53
+ },
54
+ },
55
+ security: {
56
+ cors: ["https://app.com"],
57
+ trustProxy: 1, // one load balancer between internet and app
58
+ csrf: { enabled: true }, // cookie-based auth — CSRF protection required
59
+ rateLimit: { windowMs: 60_000, max: 60 }, // tighter global cap
60
+ botProtection: {
61
+ fingerprintRateLimit: true, // IP-rotators share a rate-limit bucket
62
+ blockList: [
63
+ "198.51.100.0/24", // known datacenter / proxy ranges
64
+ "203.0.113.0/24",
65
+ ],
66
+ },
67
+ signing: {
68
+ // Session binding — ties each session to the IP + UA it was created with.
69
+ // A hijacked token presented from a different IP/UA is rejected outright.
70
+ sessionBinding: {
71
+ fields: ["ip", "ua"],
72
+ onMismatch: "reject", // 401 FINGERPRINT_MISMATCH — strict is appropriate here
73
+ },
74
+ // Require server-to-server API clients to HMAC-sign requests.
75
+ // Covers internal microservice calls and any non-browser client.
76
+ requestSigning: {
77
+ tolerance: 60_000, // 1-minute window (tighter than default 5 min)
78
+ },
79
+ },
80
+ },
81
+ });
82
+ ```
83
+
84
+ **Key decisions:**
85
+ - `mfa.required: true` — unenrolled users are blocked from service endpoints until they complete setup. OAuth users are also affected.
86
+ - 7-day refresh tokens instead of 30-day — users re-authenticate weekly, which limits the blast radius if a refresh token is stolen.
87
+ - `includeInactiveSessions: true` — lets users see a full sign-in history and spot unauthorized access the way Google and Meta do.
88
+ - `csrf.enabled` — this app uses cookie auth; CSRF is a real threat.
89
+ - Dual-keyed forgot-password rate limiting (IP + email) is automatic — the config above just tightens the window.
90
+ - `sessionBinding` with `onMismatch: "reject"` — a session token presented from a different IP or user-agent is refused, not just silently treated as unauthenticated. Appropriate here; use `"log-only"` first if rolling out to an existing user base with mobile users on dynamic IPs.
91
+ - `requestSigning` — server-to-server calls from internal services must HMAC-sign the canonical request. Prevents replay attacks and body substitution in transit.
92
+
93
+ ---
94
+
95
+ #### Consumer SaaS — social login with verification
96
+
97
+ Google and GitHub OAuth alongside credential login. Email must be verified before users can access the app. Opt-in MFA (users choose whether to enable it). Long-lived refresh tokens for a smooth "stay logged in" experience.
98
+
99
+ ```ts
100
+ await createServer({
101
+ auth: {
102
+ roles: ["admin", "member"],
103
+ defaultRole: "member",
104
+ emailVerification: {
105
+ required: true,
106
+ onSend: async (email, token) => {
107
+ await resend.emails.send({
108
+ to: email,
109
+ subject: "Confirm your email",
110
+ html: `<a href="https://myapp.com/verify?token=${token}">Verify</a>`,
111
+ });
112
+ },
113
+ },
114
+ passwordReset: {
115
+ onSend: async (email, token) => {
116
+ await resend.emails.send({
117
+ to: email,
118
+ subject: "Reset your password",
119
+ html: `<a href="https://myapp.com/reset?token=${token}">Reset password</a>`,
120
+ });
121
+ },
122
+ },
123
+ mfa: {
124
+ issuer: "MySaaS",
125
+ // required not set — users opt in via account settings
126
+ },
127
+ refreshTokens: {
128
+ accessTokenExpiry: 900, // 15 min
129
+ refreshTokenExpiry: 2_592_000, // 30 days — "stay logged in"
130
+ },
131
+ sessionPolicy: {
132
+ trackLastActive: true, // sliding sessions via refresh
133
+ },
134
+ oauth: {
135
+ postRedirect: "/dashboard",
136
+ allowedRedirectUrls: ["https://myapp.com"],
137
+ providers: {
138
+ google: {
139
+ clientId: process.env.GOOGLE_CLIENT_ID!,
140
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
141
+ redirectUri: "https://myapp.com/auth/google/callback",
142
+ },
143
+ github: {
144
+ clientId: process.env.GITHUB_CLIENT_ID!,
145
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
146
+ redirectUri: "https://myapp.com/auth/github/callback",
147
+ },
148
+ },
149
+ },
150
+ },
151
+ security: {
152
+ cors: ["https://myapp.com", "https://staging.myapp.com"],
153
+ trustProxy: 1,
154
+ csrf: { enabled: true },
155
+ botProtection: {
156
+ fingerprintRateLimit: true,
157
+ },
158
+ signing: {
159
+ // Signed cursors — prevents clients from forging pagination cursors to
160
+ // access arbitrary pages. Tampered cursors are rejected with invalidCursor.
161
+ cursors: true,
162
+ // Hash idempotency keys before storage so users can't enumerate each
163
+ // other's pending operations by guessing short numeric keys.
164
+ idempotencyKeys: true,
165
+ // Presigned download URLs — lets the browser download private files
166
+ // directly without routing bytes through the API server.
167
+ presignedUrls: { defaultExpiry: 3600 },
168
+ },
169
+ },
170
+ });
171
+ ```
172
+
173
+ **Key decisions:**
174
+ - OAuth users who share an email with a credential account get a `409` on login with a redirect to `?error=...`. They must sign in with their password and explicitly link the OAuth provider — email matching is never done automatically to prevent account takeover.
175
+ - `allowedRedirectUrls` prevents open-redirect abuse if a crafted `postRedirect` value is injected.
176
+ - MFA is opt-in, so the `mfa` block is present to enable the setup routes even though `required` is false. Users can enable it from their account page via `POST /auth/mfa/setup`.
177
+ - 30-day refresh tokens with `trackLastActive` give users a smooth experience without requiring them to log in every week.
178
+ - `cursors: true` — cursor-based pagination is used for activity feeds and search results; signing prevents users from skipping to arbitrary offsets by hand-crafting cursor values.
179
+ - `idempotencyKeys: true` + `idempotent()` middleware on payment/order routes — mount `idempotent()` on routes where duplicate submission is a real risk (checkout, subscription changes). The HMAC option prevents key enumeration if users pick short keys like `"order-1"`.
180
+ - `presignedUrls` — `GET /uploads/presign/:key` returns a time-limited HMAC URL that the browser can use to download directly from S3 (or your storage backend) without a round-trip through the API on every byte.
181
+
182
+ ---
183
+
184
+ #### Internal admin panel — re-authentication on expiry
185
+
186
+ Staff tool with two roles, MFA enforced for everyone, and no refresh tokens. When the access token expires, the session ends and the user must authenticate again. Full device history is kept for auditing.
187
+
188
+ ```ts
189
+ await createServer({
190
+ auth: {
191
+ roles: ["superadmin", "admin", "viewer"],
192
+ defaultRole: "viewer",
193
+ mfa: {
194
+ issuer: "Admin Panel",
195
+ required: true,
196
+ challengeTtlSeconds: 120, // 2-minute window for MFA code
197
+ },
198
+ passwordPolicy: {
199
+ minLength: 14,
200
+ requireLetter: true,
201
+ requireDigit: true,
202
+ requireSpecial: true,
203
+ },
204
+ sessionPolicy: {
205
+ maxSessions: 2, // work machine + phone; third device evicts oldest
206
+ trackLastActive: true,
207
+ persistSessionMetadata: true,
208
+ includeInactiveSessions: true, // audit trail of all sign-ins
209
+ },
210
+ rateLimit: {
211
+ login: { windowMs: 15 * 60 * 1000, max: 5 },
212
+ },
213
+ // No refreshTokens — 7-day JWTs, then re-authenticate.
214
+ // For a stricter posture: add refreshTokens with a 4h refreshTokenExpiry.
215
+ //
216
+ // No oauth — staff use internal credentials only.
217
+ // No emailVerification — IT provisions accounts directly.
218
+ },
219
+ security: {
220
+ cors: ["https://admin.internal.myapp.com"],
221
+ trustProxy: 1,
222
+ csrf: { enabled: true },
223
+ rateLimit: { windowMs: 60_000, max: 30 }, // low — this is not a public endpoint
224
+ },
225
+ });
226
+ ```
227
+
228
+ **Key decisions:**
229
+ - No `refreshTokens` — when the token expires, the user re-enters credentials. This matches the mental model of a work session and limits how long a compromised token stays valid.
230
+ - `maxSessions: 2` — a staff member logging in from a third device quietly evicts the oldest session. Combined with `includeInactiveSessions: true` and email alerts in `onBeforeDelete`, this can surface unusual access.
231
+ - `requireRole.global("admin")` or `requireRole.global("superadmin")` on sensitive routes — tenant-scoped roles never satisfy a global check.
232
+
233
+ ---
234
+
235
+ #### Native mobile / server-to-server — no cookies, no CSRF
236
+
237
+ For **native iOS/Android apps and server-to-server API clients** that pass the JWT in the `x-user-token` header and never touch cookies. CSRF is irrelevant here because the attack requires a browser to silently attach credentials — custom headers can't be forged that way. Phone numbers as the primary identifier.
238
+
239
+ > **Not for browser SPAs.** If your client is a browser app (React, Vue, etc.), see the note after this example — even header-based browser clients need specific CORS origins.
240
+
241
+ ```ts
242
+ await createServer({
243
+ auth: {
244
+ primaryField: "phone", // body field is "phone" instead of "email"
245
+ passwordPolicy: {
246
+ minLength: 8,
247
+ requireDigit: true,
248
+ },
249
+ mfa: {
250
+ issuer: "MyApp",
251
+ emailOtp: {
252
+ onSend: async (email, code) => {
253
+ // or route to an SMS gateway keyed by the user's phone number
254
+ await mailer.send(email, `Your login code: ${code}`);
255
+ },
256
+ codeLength: 6,
257
+ },
258
+ },
259
+ refreshTokens: {
260
+ accessTokenExpiry: 900, // 15 min
261
+ refreshTokenExpiry: 2_592_000, // 30 days
262
+ rotationGraceSeconds: 60, // more generous — mobile networks are lossy
263
+ },
264
+ sessionPolicy: {
265
+ maxSessions: 5, // phone, tablet, desktop, smart TV, etc.
266
+ },
267
+ // No emailVerification — phone is the primary field; email may not be present.
268
+ // No csrf — header-based auth is immune to CSRF.
269
+ },
270
+ security: {
271
+ cors: "*", // safe: native apps don't run in a browser, CORS has no effect
272
+ trustProxy: 1,
273
+ botProtection: {
274
+ fingerprintRateLimit: true,
275
+ blockList: ["198.51.100.0/24"],
276
+ },
277
+ rateLimit: { windowMs: 60_000, max: 200 }, // higher cap for mobile traffic bursts
278
+ },
279
+ });
280
+ ```
281
+
282
+ **Key decisions:**
283
+ - `primaryField: "phone"` — register/login bodies use `{ phone, password }`. Email verification and password reset routes are not mounted (they require `primaryField: "email"`).
284
+ - `cors: "*"` is safe for native apps because CORS is a browser enforcement mechanism — it has no meaning outside of a browser context. A native app making HTTP requests ignores CORS entirely.
285
+ - `rotationGraceSeconds: 60` — mobile connections drop mid-request more often than browser connections. A longer grace window prevents legitimate refresh retries from triggering theft detection.
286
+ - No `csrf` block — CSRF attacks require the browser to automatically attach credentials (cookies). Since this API uses `x-user-token`, there's nothing to forge.
287
+
288
+ **Adding session binding to mobile apps:** Mobile users frequently switch between Wi-Fi and LTE, so IP-based binding causes false rejections. If you want session binding, use UA only and start with `"log-only"` to measure false-positive rates before switching to `"reject"`:
289
+
290
+ ```ts
291
+ signing: {
292
+ sessionBinding: {
293
+ fields: ["ua"], // IP excluded — mobile IPs change too often
294
+ onMismatch: "log-only", // measure first, then tighten to "unauthenticate"
295
+ },
296
+ },
297
+ ```
298
+
299
+ ##### Browser SPA with header-based auth
300
+
301
+ If your client is a browser app that uses `x-user-token` instead of cookies — common with React/Vue SPAs that manage tokens in memory — the auth config is the same but CORS must be locked down:
302
+
303
+ ```ts
304
+ security: {
305
+ cors: ["https://myapp.com"], // NOT "*" — browsers enforce CORS even for header auth
306
+ trustProxy: 1,
307
+ // No csrf — custom headers can't be forged cross-origin without a CORS allowlist,
308
+ // so CSRF protection is still not needed even in the browser.
309
+ botProtection: {
310
+ fingerprintRateLimit: true,
311
+ },
312
+ },
313
+ ```
314
+
315
+ The reason CSRF is still not needed: a malicious page can try to make a cross-origin `POST` to your API, but the browser blocks requests with custom headers (`x-user-token`) unless the server's CORS policy explicitly allows the origin. Since `cors` is locked to `["https://myapp.com"]`, any request from a different origin is rejected before it reaches your handler. The CSRF double-submit pattern defends against cookie theft specifically; `x-user-token` doesn't have that vulnerability.
316
+
317
+ **What to avoid:** storing the token in `localStorage` is convenient but exposes it to XSS. Storing it in a `httpOnly` cookie eliminates XSS risk but brings CSRF back — at which point the [Consumer SaaS](#consumer-saas--social-login-with-verification) or [Fintech](#fintech--healthcare--maximum-security) example is the right template instead.
318
+
319
+ ---
320
+
321
+ #### Local development — no external dependencies
322
+
323
+ Everything in-memory. No Redis, no MongoDB, no Docker required. Spin up with `bun run dev` and iterate immediately. All data is lost on restart, which is fine for local work.
324
+
325
+ ```ts
326
+ await createServer({
327
+ routesDir: import.meta.dir + "/routes",
328
+ db: {
329
+ mongo: false,
330
+ redis: false,
331
+ auth: "memory",
332
+ sessions: "memory",
333
+ },
334
+ auth: {
335
+ roles: ["admin", "user"],
336
+ defaultRole: "user",
337
+ passwordPolicy: {
338
+ minLength: 1, // any password works — don't slow down local testing
339
+ requireLetter: false,
340
+ requireDigit: false,
341
+ requireSpecial: false,
342
+ },
343
+ rateLimit: {
344
+ login: { windowMs: 60_000, max: 10_000 }, // effectively unlimited
345
+ register: { windowMs: 60_000, max: 10_000 },
346
+ },
347
+ // To test email flows locally, add emailVerification / passwordReset with
348
+ // an onSend that logs the token to the console instead of sending a real email:
349
+ //
350
+ // emailVerification: {
351
+ // onSend: async (email, token) => console.log("[verify]", email, token),
352
+ // },
353
+ },
354
+ security: {
355
+ cors: "*",
356
+ bearerAuth: false, // no API key required
357
+ },
358
+ });
359
+ ```
360
+
361
+ **Key decisions:**
362
+ - `db: { mongo: false, redis: false }` is the key line — it disables all auto-connect calls, so no connection errors on startup.
363
+ - `auth: "memory"` and `sessions: "memory"` use in-memory Maps. Import `clearMemoryStore()` from the package in your tests to reset state between test runs.
364
+ - Rate limits are set to 10 000 per minute rather than disabled entirely so the rate-limit code path is exercised (avoiding "works in dev, breaks in prod" surprises). The limit is just high enough that it never triggers during normal use.
365
+ - If you want to test email flows locally without a real provider, the commented-out block shows how to log the token to the console — paste it into the browser or a `curl` call directly.
366
+
367
+ ---
368
+
369
+ ## JWT Claims (iss, aud, iat)
370
+
371
+ By default Bunshot tokens include `sub`, `sid`, and `exp`. Enable standard JWT claims for multi-service deployments or compliance requirements:
372
+
373
+ ```ts
374
+ createApp({
375
+ auth: {
376
+ jwt: {
377
+ issuer: "https://auth.yourapp.com", // iss claim — who issued the token
378
+ audience: "your-api", // aud claim — who the token is for
379
+ },
380
+ },
381
+ });
382
+ ```
383
+
384
+ When configured:
385
+ - **`iss`** and **`aud`** are included in every token and validated on every verification. Tokens from a different issuer or intended for a different audience are rejected.
386
+ - **`iat`** (issued-at) is always included once JWT config is set. Use it to detect token reuse or implement absolute expiry windows.
387
+
388
+ This is recommended for fintech and multi-tenant deployments where tokens from one service should not be accepted by another.
@@ -0,0 +1,130 @@
1
+ ## Authentication
2
+
3
+ Bunshot ships a complete auth system: credential login, OAuth social providers (Google, Apple, Microsoft, GitHub), multi-factor authentication (TOTP, email OTP, WebAuthn), session management, roles, groups, and security hardening (CSRF, rate limiting, bot protection). Everything is opt-in — add only what your app needs.
4
+
5
+ ### How it works
6
+
7
+ Auth has two independent layers you configure separately:
8
+
9
+ | Layer | What it stores | Configured via | Default |
10
+ |---|---|---|---|
11
+ | **Auth adapter** | Users, passwords, roles | `auth.adapter` or `db.auth` | MongoDB (`mongoAuthAdapter`) |
12
+ | **Session store** | Active sessions (JWT metadata) | `db.sessions` | Redis |
13
+
14
+ When a user logs in, the auth adapter verifies their credentials and the session store creates a record for the new session. The JWT embeds a `sessionId` claim that ties the token to that record — revoking a session immediately invalidates its token even before the JWT expires.
15
+
16
+ ### Minimum working setup
17
+
18
+ Enable auth by passing an `auth` block. The routes are mounted automatically.
19
+
20
+ ```ts
21
+ await createServer({
22
+ routesDir: import.meta.dir + "/routes",
23
+ auth: {
24
+ roles: ["admin", "user"],
25
+ defaultRole: "user",
26
+ },
27
+ });
28
+ ```
29
+
30
+ This mounts `POST /auth/register`, `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, and the session management endpoints. Users are stored in MongoDB, sessions in Redis.
31
+
32
+ **Environment variables required:**
33
+
34
+ ```bash
35
+ MONGO_URI_DEV=mongodb://localhost:27017/myapp
36
+ REDIS_URL_DEV=redis://localhost:6379
37
+ JWT_SECRET_DEV=at-least-32-characters-long-secret
38
+ ```
39
+
40
+ ### Choosing a store
41
+
42
+ | Setup | `db.auth` | `db.sessions` | When to use |
43
+ |---|---|---|---|
44
+ | Default (Mongo + Redis) | `"mongo"` | `"redis"` | Production with both services |
45
+ | Mongo only | `"mongo"` | `"mongo"` | When Redis is unavailable |
46
+ | SQLite | `"sqlite"` | `"sqlite"` | Lightweight deploys, embedded DBs |
47
+ | Memory | `"memory"` | `"memory"` | Tests, local dev — lost on restart |
48
+
49
+ ```ts
50
+ // MongoDB sessions instead of Redis
51
+ await createServer({
52
+ db: { redis: false, sessions: "mongo" },
53
+ auth: { /* ... */ },
54
+ });
55
+
56
+ // SQLite — single file, no external services
57
+ await createServer({
58
+ db: { mongo: false, redis: false, sqlite: import.meta.dir + "/data.db", auth: "sqlite", sessions: "sqlite" },
59
+ auth: { /* ... */ },
60
+ });
61
+
62
+ // In-memory — great for tests
63
+ await createServer({
64
+ db: { mongo: false, redis: false, auth: "memory", sessions: "memory" },
65
+ auth: { /* ... */ },
66
+ });
67
+ ```
68
+
69
+ ### Protecting routes
70
+
71
+ ```ts
72
+ import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
73
+
74
+ router.use("/me", userAuth); // 401 if not logged in
75
+ router.use("/admin", userAuth, requireRole("admin")); // 403 if wrong role
76
+ router.use("/content", userAuth, requireRole("admin", "editor")); // either role passes
77
+ router.use("/settings", userAuth, requireVerifiedEmail); // 403 if email unverified
78
+ ```
79
+
80
+ ### Feature map
81
+
82
+ Everything beyond basic credential login is opt-in:
83
+
84
+ | Feature | How to enable | Docs |
85
+ |---|---|---|
86
+ | Email verification | `auth.emailVerification` | [Auth Flow](#auth-flow) |
87
+ | Password reset | `auth.passwordReset` | [Auth Flow](#auth-flow) |
88
+ | Refresh tokens | `auth.refreshTokens` | [Auth Flow](#auth-flow) |
89
+ | MFA (TOTP, email OTP, WebAuthn) | `auth.mfa` | [Auth Flow](#auth-flow) |
90
+ | Social login (OAuth) | `auth.oauth.providers` | [Social Login](#social-login-oauth) |
91
+ | Roles & groups | `auth.roles`, `groups` | [Roles](#roles) |
92
+ | CSRF protection | `security.csrf` | [Auth Flow](#auth-flow) |
93
+ | Bot protection | `security.botProtection` | [Auth Flow](#auth-flow) |
94
+ | Account deletion | `auth.accountDeletion` | [Auth Flow](#auth-flow) |
95
+ | Custom user store | `auth.adapter` | [Auth Flow](#auth-flow) |
96
+
97
+ ### Custom auth adapter
98
+
99
+ The default adapter stores users in MongoDB. Pass `auth.adapter` to use any other store — Postgres, SQLite, an external identity provider, etc. Only implement the methods your app uses:
100
+
101
+ ```ts
102
+ import type { AuthAdapter } from "@lastshotlabs/bunshot";
103
+
104
+ const myAdapter: AuthAdapter = {
105
+ async findByEmail(email) {
106
+ const user = await db.query("SELECT id, passwordHash FROM users WHERE email = $1", [email]);
107
+ return user ?? null;
108
+ },
109
+ async create(email, passwordHash) {
110
+ const [user] = await db.query(
111
+ "INSERT INTO users (email, passwordHash) VALUES ($1, $2) RETURNING id",
112
+ [email, passwordHash]
113
+ );
114
+ return { id: user.id };
115
+ },
116
+ async getRoles(userId) {
117
+ const user = await db.query("SELECT roles FROM users WHERE id = $1", [userId]);
118
+ return user?.roles ?? [];
119
+ },
120
+ async setRoles(userId, roles) {
121
+ await db.query("UPDATE users SET roles = $2 WHERE id = $1", [userId, roles]);
122
+ },
123
+ };
124
+
125
+ await createServer({
126
+ auth: { adapter: myAdapter, roles: ["admin", "user"], defaultRole: "user" },
127
+ });
128
+ ```
129
+
130
+ The full adapter interface and all optional methods (OAuth, MFA, tenant roles, groups, etc.) are covered in [Auth Flow](#auth-flow).
@@ -0,0 +1,5 @@
1
+ ## Authentication
2
+
3
+ Bunshot ships a complete auth system: credential login, OAuth social providers (Google, Apple, Microsoft, GitHub), MFA (TOTP, email OTP, WebAuthn), session management, roles & groups, and security hardening (CSRF, rate limiting, bot protection). Everything is opt-in and configurable.
4
+
5
+ Auth has two independently configurable layers: the **auth adapter** (where users and passwords are stored — MongoDB by default, or SQLite/memory/custom) and the **session store** (where active sessions live — Redis by default, or MongoDB/SQLite/memory). Sessions are JWT-backed with a `sessionId` claim, so revoking a session invalidates the token immediately even before it expires. Protect routes with `userAuth`, `requireRole`, and `requireVerifiedEmail` middleware.
@@ -27,4 +27,16 @@ my-app/
27
27
  .env # environment variables template
28
28
  ```
29
29
 
30
- Path aliases like `@config/*`, `@lib/*`, `@middleware/*`, `@models/*`, `@routes/*`, `@services/*`, and `@workers/*` are set up automatically in `tsconfig.json`.
30
+ Path aliases like `@config/*`, `@lib/*`, `@middleware/*`, `@models/*`, `@routes/*`, `@services/*`, and `@workers/*` are set up automatically in `tsconfig.json`.
31
+
32
+ After the database setup, the CLI prompts for an **auth security posture**. You can either pick a preset (one question) or configure features step by step:
33
+
34
+ **Presets:**
35
+ - **Web app / SaaS** — CSRF protection, refresh tokens, bot-fingerprint rate limiting. Includes commented-out stubs for email verification, password reset, and MFA.
36
+ - **Internal / admin** — MFA required for all users, strict password policy (14+ chars), low login rate limits, tight session cap.
37
+ - **Mobile / API only** — No CSRF, open CORS, long-lived refresh tokens with rotation grace window.
38
+ - **Dev / prototype** — Permissive password policy, very high rate limits, no bearer auth guard.
39
+
40
+ **Step by step** — choose individual features: password policy, email verification, password reset, refresh tokens, MFA (none / optional / required), CSRF, and OAuth providers (Google, GitHub, Apple, Microsoft).
41
+
42
+ The selected posture is printed in the end-of-run summary and reflected directly in the generated `src/config/index.ts`.
@@ -125,6 +125,23 @@ await createServer({
125
125
  },
126
126
  },
127
127
 
128
+ // Validation error formatting
129
+ validation: {
130
+ // Custom formatter for Zod validation errors. Receives issues + requestId, returns JSON body.
131
+ // Default produces: { error: "msg1, msg2", details: [{ path, message }], requestId }
132
+ formatError: (issues, requestId) => ({
133
+ errors: issues.map((i) => ({ field: i.path.join("."), message: i.message })),
134
+ requestId,
135
+ }),
136
+ },
137
+
138
+ // API versioning — isolates routes into per-version OpenAPI specs
139
+ versioning: {
140
+ versions: ["v1", "v2"], // subdirectories under routesDir (routes/v1/, routes/v2/)
141
+ defaultVersion: "v2", // /docs and /openapi.json redirect here (default: last in array)
142
+ sharedDir: "shared", // routes/shared/ — mounted on all versions, unprefixed schemas (default: "shared", false to disable)
143
+ },
144
+
128
145
  // Extra middleware injected after identify, before route matching
129
146
  middleware: [],
130
147
 
@@ -13,5 +13,6 @@
13
13
  | `jobs` | Job status REST endpoint config |
14
14
  | `ws` | WebSocket handler and upgrade overrides |
15
15
  | `middleware` | Extra global middleware array |
16
+ | `validation` | Zod validation error formatting (`formatError` callback) |
16
17
  | `modelSchemas` | Schema auto-discovery paths |
17
18
  | `port`, `workersDir`, `enableWorkers` | Server options |
@@ -17,7 +17,21 @@ import {
17
17
  // WebSocket
18
18
  websocket, createWsUpgradeHandler, publish,
19
19
  subscribe, unsubscribe, getSubscriptions, handleRoomActions,
20
- getRooms, getRoomSubscribers,
20
+ getRooms, getRoomSubscribers, setPresenceEnabled,
21
+
22
+ // WebSocket — Heartbeat
23
+ registerSocket, deregisterSocket, handlePong,
24
+ startHeartbeat, stopHeartbeat, clearHeartbeatState,
25
+ type HeartbeatConfig,
26
+
27
+ // WebSocket — Presence
28
+ trackSocket, untrackSocket, addPresence, removePresence, cleanupPresence,
29
+ getRoomPresence, getUserPresence, clearPresenceStore,
30
+
31
+ // WebSocket — Message Persistence
32
+ persistMessage, getMessageHistory, configureRoom,
33
+ setWsMessageStore, setWsMessageDefaults, clearWsMessageMemoryStore,
34
+ type StoredMessage, type WsMessageStore, type WsMessageDefaults, type RoomPersistenceConfig,
21
35
 
22
36
  // Auth utilities
23
37
  signToken, verifyToken,
@@ -51,17 +65,30 @@ import {
51
65
  requireRole, // role-based access control (tenant-aware)
52
66
  requireVerifiedEmail, // blocks unverified email addresses
53
67
  cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching (tenant-namespaced)
68
+ webhookAuth, // HMAC signature verification for incoming webhooks
69
+ type WebhookAuthOptions, type WebhookTimestampOptions,
54
70
 
55
- // Crypto utilities
71
+ // Crypto / HMAC utilities
56
72
  timingSafeEqual, // constant-time string comparison for secrets/hashes
57
73
  sha256, // SHA-256 hash helper
74
+ hmacSign, hmacVerify, // low-level HMAC-SHA256 primitives (key rotation supported)
75
+ signCookieValue, verifyCookieValue, // signed cookie values (tamper-proof)
76
+ signCursor, verifyCursor, // signed pagination cursors
77
+ createPresignedUrl, verifyPresignedUrl, // stateless HMAC presigned URLs
78
+ idempotent, setIdempotencyStore, clearIdempotencyMemoryStore, // idempotency deduplication
79
+ type IdempotencyOptions,
80
+ requireSignedRequest, // middleware: require HMAC-signed requests
81
+ type RequestSigningOptions,
82
+ maybeSignCursor, // sign a cursor if signing.cursors is enabled
58
83
 
59
84
  // IP / proxy utilities
60
85
  getClientIp, // centralized IP extraction — respects security.trustProxy setting
61
86
  setTrustProxy, // configure trust level (called automatically by createApp)
62
87
 
63
88
  // Utilities
64
- HttpError, log, validate, createRouter, createRoute,
89
+ HttpError, ValidationError, // ValidationError extends HttpError, carries ZodIssue[]
90
+ defaultValidationErrorFormatter, // default { error, details, requestId } shape
91
+ log, validate, createRouter, createRoute,
65
92
  registerSchema, registerSchemas, // named OpenAPI schema registration
66
93
  zodToMongoose, // Zod → Mongoose schema conversion
67
94
  createDtoMapper, // DB document → API DTO mapper factory
@@ -71,9 +98,13 @@ import {
71
98
  // Constants
72
99
  COOKIE_TOKEN, HEADER_USER_TOKEN,
73
100
  COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN, // refresh token cookie/header names
101
+ HEADER_IDEMPOTENCY_KEY, // "idempotency-key"
102
+ HEADER_SIGNATURE, HEADER_TIMESTAMP, // request signing header names
74
103
 
75
104
  // Types
76
105
  type AppEnv, type AppVariables,
106
+ type ValidationErrorFormatter, type DefaultValidationErrorBody, type ValidationErrorDetail,
107
+ type ValidationConfig,
77
108
  type CreateServerConfig, type CreateAppConfig, type ModelSchemasConfig,
78
109
  type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
79
110
  type PrimaryField, type EmailVerificationConfig, type PasswordResetConfig,