@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
@@ -1,16 +1,24 @@
1
- ## Auth Flow
1
+ ### Auth Flow
2
2
 
3
3
  Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
4
4
 
5
- ### Browser clients
5
+ #### Browser clients
6
6
  1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
7
7
  2. All subsequent requests send the cookie — no extra code needed
8
8
 
9
- ### API / non-browser clients
9
+ #### API / non-browser clients
10
10
  1. `POST /auth/login` → read `token` from response body
11
11
  2. Send `x-user-token: <token>` header on every request
12
12
 
13
- ### Session management
13
+ #### Current user
14
+
15
+ ```
16
+ GET /auth/me → { userId, email?, emailVerified?, googleLinked? }
17
+ ```
18
+
19
+ Requires an active session (cookie or `x-user-token` header). Returns the authenticated user's profile — `email`, `emailVerified`, and `googleLinked` are populated when the auth adapter implements `getUser`.
20
+
21
+ #### Session management
14
22
 
15
23
  Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.
16
24
 
@@ -24,11 +32,11 @@ Session metadata (IP address, user-agent, timestamps) is persisted even after a
24
32
 
25
33
  Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.
26
34
 
27
- #### Sliding sessions
35
+ ##### Sliding sessions
28
36
 
29
37
  Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.
30
38
 
31
- ### Refresh Tokens
39
+ #### Refresh Tokens
32
40
 
33
41
  When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.
34
42
 
@@ -46,7 +54,7 @@ await createServer({
46
54
 
47
55
  **When not configured**, the existing 7-day JWT behavior is unchanged — fully backward compatible.
48
56
 
49
- #### Endpoints
57
+ ##### Endpoints
50
58
 
51
59
  | Endpoint | Purpose |
52
60
  |---|---|
@@ -54,15 +62,15 @@ await createServer({
54
62
  | `POST /auth/register` | Returns `token` + `refreshToken` |
55
63
  | `POST /auth/refresh` | Rotates refresh token, returns new `token` + `refreshToken` |
56
64
 
57
- #### Rotation with grace window
65
+ ##### Rotation with grace window
58
66
 
59
67
  On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused *after* the grace window, the entire session is invalidated — this is token-family theft detection.
60
68
 
61
- #### Cookie behavior
69
+ ##### Cookie behavior
62
70
 
63
- The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it's also returned in the JSON body and accepted via the `x-refresh-token` header.
71
+ The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it is accepted via the `x-refresh-token` header or in the request body. **The refresh token is not returned in the JSON response body** it is only delivered via the `HttpOnly` cookie to prevent accidental exposure in logs or client-side code.
64
72
 
65
- ### MFA / TOTP
73
+ #### MFA / TOTP
66
74
 
67
75
  Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
68
76
 
@@ -87,7 +95,7 @@ Requires `otpauth` peer dependency:
87
95
  bun add otpauth
88
96
  ```
89
97
 
90
- #### Endpoints
98
+ ##### Endpoints
91
99
 
92
100
  | Endpoint | Auth | Purpose |
93
101
  |---|---|---|
@@ -98,7 +106,7 @@ bun add otpauth
98
106
  | `POST /auth/mfa/recovery-codes` | userAuth | Regenerate codes (requires TOTP code) |
99
107
  | `GET /auth/mfa/methods` | userAuth | Get enabled MFA methods |
100
108
 
101
- #### Login flow with MFA enabled
109
+ ##### Login flow with MFA enabled
102
110
 
103
111
  1. `POST /auth/login` with credentials → password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
104
112
  2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → verifies TOTP or recovery code → creates session → returns normal token response
@@ -109,7 +117,45 @@ The verify endpoint accepts an optional `method` field (`"totp"` or `"emailOtp"`
109
117
 
110
118
  **Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.
111
119
 
112
- ### Email OTP
120
+ ##### Enforcing MFA for all users
121
+
122
+ By default MFA is opt-in — individual users choose whether to enable it. Set `required: true` to enforce MFA at the app level:
123
+
124
+ ```ts
125
+ await createServer({
126
+ auth: {
127
+ mfa: {
128
+ issuer: "My App",
129
+ required: true, // all authenticated users must complete MFA setup
130
+ },
131
+ },
132
+ });
133
+ ```
134
+
135
+ When `required` is `true`:
136
+
137
+ - Authenticated users who have **not** completed MFA setup receive a `403` response on all non-auth endpoints:
138
+ ```json
139
+ { "error": "MFA setup required", "code": "MFA_SETUP_REQUIRED" }
140
+ ```
141
+ - **Exempt paths** remain accessible so users can complete setup: all `/auth/*` routes (login, logout, register, MFA setup, OAuth, sessions), `/health`, `/docs`, `/openapi.json`, and the root `/`.
142
+ - **Unauthenticated requests** pass through normally — the middleware only gates users who are logged in but lack MFA.
143
+ - **OAuth users** must also set up MFA — OAuth login creates a session, but the user is still blocked from service endpoints until MFA is configured.
144
+ - **Disabling MFA** (via `DELETE /auth/mfa`) when `required: true` immediately blocks the user from service endpoints until they re-enable it.
145
+
146
+ **Client-side handling:** Check for the `MFA_SETUP_REQUIRED` code in 403 responses and redirect users to an MFA setup page that calls `POST /auth/mfa/setup`.
147
+
148
+ **Per-route usage:** The `requireMfaSetup` middleware is also exported for apps that want manual, per-route enforcement instead of global:
149
+
150
+ ```ts
151
+ import { userAuth, requireMfaSetup } from "@lastshotlabs/bunshot";
152
+
153
+ router.get("/dashboard", userAuth, requireMfaSetup, (c) => {
154
+ return c.json({ message: "Welcome" });
155
+ });
156
+ ```
157
+
158
+ #### Email OTP
113
159
 
114
160
  An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
115
161
 
@@ -129,7 +175,7 @@ await createServer({
129
175
  });
130
176
  ```
131
177
 
132
- #### Endpoints
178
+ ##### Endpoints
133
179
 
134
180
  | Endpoint | Auth | Purpose |
135
181
  |---|---|---|
@@ -138,26 +184,26 @@ await createServer({
138
184
  | `DELETE /auth/mfa/email-otp` | userAuth | Disable email OTP |
139
185
  | `POST /auth/mfa/resend` | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
140
186
 
141
- #### Setup flow
187
+ ##### Setup flow
142
188
 
143
189
  1. `POST /auth/mfa/email-otp/enable` → sends code to email → returns `{ setupToken }`
144
190
  2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` → enables email OTP → returns recovery codes
145
191
 
146
192
  This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
147
193
 
148
- #### Login flow with email OTP
194
+ ##### Login flow with email OTP
149
195
 
150
196
  1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
151
197
  2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → creates session
152
198
  3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)
153
199
 
154
- #### Disabling email OTP
200
+ ##### Disabling email OTP
155
201
 
156
202
  - If TOTP is also enabled: requires a TOTP code in the `code` field
157
203
  - If email OTP is the only method: requires the account password in the `password` field
158
204
  - Disabling the last MFA method turns off MFA entirely
159
205
 
160
- ### WebAuthn / Security Keys
206
+ #### WebAuthn / Security Keys
161
207
 
162
208
  Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.
163
209
 
@@ -186,7 +232,7 @@ bun add @simplewebauthn/server
186
232
 
187
233
  If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.
188
234
 
189
- #### Endpoints
235
+ ##### Endpoints
190
236
 
191
237
  | Endpoint | Auth | Purpose |
192
238
  |---|---|---|
@@ -196,13 +242,15 @@ If `mfa.webauthn` is configured but the dependency is missing, the server fails
196
242
  | `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key |
197
243
  | `DELETE /auth/mfa/webauthn` | userAuth | Disable WebAuthn entirely |
198
244
 
199
- #### Registration flow
245
+ ##### Registration flow
200
246
 
201
247
  1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
202
248
  2. Client passes `options` to `navigator.credentials.create()` — browser prompts user to tap/scan key
203
249
  3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` → stores credential → returns recovery codes
204
250
 
205
- #### Login flow with WebAuthn
251
+ > Credentials are registered with `residentKey: "required"` and `userVerification: "required"`, making them usable as discoverable passkeys. This enables passwordless login via `allowPasswordlessLogin` without requiring users to re-register.
252
+
253
+ ##### Login flow with WebAuthn
206
254
 
207
255
  1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
208
256
  2. Client passes `webauthnOptions` to `navigator.credentials.get()` — browser prompts for key
@@ -210,17 +258,87 @@ If `mfa.webauthn` is configured but the dependency is missing, the server fails
210
258
 
211
259
  The `webauthnOptions` object follows the WebAuthn spec — pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.
212
260
 
213
- #### Credential removal
261
+ ##### Credential removal
214
262
 
215
263
  - Removing a spare key (other keys or MFA methods still active): no extra verification needed
216
264
  - Removing the last credential of the last MFA method: requires TOTP code or password
217
265
  - `DELETE /auth/mfa/webauthn` (disable all): always requires verification
218
266
 
219
- #### Sign count validation
267
+ ##### Sign count validation
220
268
 
221
269
  WebAuthn authenticators increment a sign count on each use to detect cloned keys. By default, a backward count logs a warning but allows authentication. Set `strictSignCount: true` to reject authentication when the count goes backward.
222
270
 
223
- ### Account Deletion
271
+ #### Email Verification
272
+
273
+ Opt-in via `auth.emailVerification`. Requires `primaryField: "email"` (default).
274
+
275
+ ```ts
276
+ await createServer({
277
+ auth: {
278
+ emailVerification: {
279
+ onSend: async (email, token) => {
280
+ // Send via any provider (Resend, SendGrid, etc.)
281
+ await sendEmail(email, `Verify your email: https://myapp.com/verify?token=${token}`);
282
+ },
283
+ required: true, // Block login until verified (default: false)
284
+ tokenExpiry: 86400, // Seconds — default: 86400 (24 hours)
285
+ },
286
+ },
287
+ });
288
+ ```
289
+
290
+ When configured, two routes are mounted:
291
+
292
+ | Endpoint | Auth | Purpose |
293
+ |---|---|---|
294
+ | `POST /auth/verify-email` | none | Consume verification token |
295
+ | `POST /auth/resend-verification` | none (uses credentials) | Resend verification email |
296
+
297
+ A verification token is sent automatically on registration via `emailVerification.onSend`. The token is SHA-256 hashed before storage. Rate-limited by IP (verify) and identifier (resend).
298
+
299
+ When `required: true`, unverified users receive `403 { error: "Email not verified" }` on `/auth/login`.
300
+
301
+ #### Password Reset
302
+
303
+ Opt-in via `auth.passwordReset`. Requires `primaryField: "email"` (default) and a `setPassword` implementation on your auth adapter.
304
+
305
+ ```ts
306
+ await createServer({
307
+ auth: {
308
+ passwordReset: {
309
+ onSend: async (email, token) => {
310
+ await sendEmail(email, `Reset your password: https://myapp.com/reset?token=${token}`);
311
+ },
312
+ tokenExpiry: 3600, // Seconds — default: 3600 (1 hour)
313
+ },
314
+ },
315
+ });
316
+ ```
317
+
318
+ When configured, two routes are mounted:
319
+
320
+ | Endpoint | Auth | Purpose |
321
+ |---|---|---|
322
+ | `POST /auth/forgot-password` | none | Request reset email (always returns 200 — never reveals whether address is registered) |
323
+ | `POST /auth/reset-password` | none | Consume token, set new password, revoke all sessions |
324
+
325
+ The forgot-password endpoint uses a fire-and-forget pattern and is rate-limited by **both IP and email address** to prevent distributed email-bombing. Tokens are SHA-256 hashed before storage and single-use. After a successful reset, all active sessions are revoked.
326
+
327
+ #### Primary Field
328
+
329
+ By default, users register and log in with their email address. Change this via `auth.primaryField`:
330
+
331
+ ```ts
332
+ await createServer({
333
+ auth: {
334
+ primaryField: "username", // "email" (default) | "username" | "phone"
335
+ },
336
+ });
337
+ ```
338
+
339
+ When set to `"username"` or `"phone"`, the registration/login body uses that field name instead of `email`. Email verification and password reset are only available when `primaryField` is `"email"`. Implement `adapter.findByIdentifier` on your custom adapter for non-email primary fields — it falls back to `findByEmail` when absent.
340
+
341
+ #### Account Deletion
224
342
 
225
343
  Enable `DELETE /auth/me` for user-initiated account deletion:
226
344
 
@@ -245,18 +363,47 @@ await createServer({
245
363
  });
246
364
  ```
247
365
 
248
- #### Behavior
366
+ ##### Behavior
249
367
 
250
368
  - Requires `userAuth` middleware (user must be logged in)
251
369
  - Body: `{ password?: string }` — required for credential accounts, skipped for OAuth-only
252
370
  - Revokes all sessions, deletes tokens, calls `adapter.deleteUser(userId)`
253
371
  - Rate limited (3/hour by userId)
254
372
 
255
- #### Queued deletion
373
+ ##### Queued deletion
256
374
 
257
- When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
375
+ When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately and the user's sessions are revoked at that point. The actual user data deletion executes after the delay set by `gracePeriod`.
258
376
 
259
- ### Password Policy
377
+ The framework starts a built-in BullMQ worker automatically (in-process, alongside the API server) to process deletion jobs. Requires BullMQ and Redis:
378
+
379
+ ```bash
380
+ bun add bullmq
381
+ ```
382
+
383
+ With `gracePeriod > 0`, the `POST /auth/cancel-deletion` route is mounted. Provide `onDeletionScheduled` to send the cancel token to the user:
384
+
385
+ ```ts
386
+ auth: {
387
+ accountDeletion: {
388
+ queued: true,
389
+ gracePeriod: 7 * 24 * 60 * 60, // 7 days in seconds
390
+ onDeletionScheduled: async (userId, email, cancelToken) => {
391
+ await sendEmail(email, `Cancel deletion: https://myapp.com/cancel?token=${cancelToken}`);
392
+ },
393
+ },
394
+ }
395
+ ```
396
+
397
+ The cancel endpoint accepts the token and removes the pending BullMQ job:
398
+
399
+ ```
400
+ POST /auth/cancel-deletion → { message: "Account deletion cancelled" }
401
+ Body: { token: string }
402
+ ```
403
+
404
+ The cancel token expires when the grace period elapses and can only be used once.
405
+
406
+ #### Password Policy
260
407
 
261
408
  Configure password complexity requirements via `auth.passwordPolicy`. The policy applies to registration and password reset — login uses `min(1)` intentionally to avoid locking out users registered under older/weaker policies.
262
409
 
@@ -275,7 +422,7 @@ await createServer({
275
422
 
276
423
  When not configured, the default policy requires 8+ characters with at least one letter and one digit.
277
424
 
278
- ### Protecting routes
425
+ #### Protecting routes
279
426
 
280
427
  ```ts
281
428
  import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
@@ -286,7 +433,7 @@ router.use("/content", userAuth, requireRole("admin", "editor")); // allow eithe
286
433
  router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
287
434
  ```
288
435
 
289
- ### Custom auth adapter
436
+ #### Custom auth adapter
290
437
 
291
438
  By default, `/auth/*` routes store users in MongoDB via `mongoAuthAdapter`. Pass `auth: { adapter: myAdapter }` to `createServer` to use any other store — Postgres, SQLite, an external service, etc. Alternatively, use `db.auth` to select a built-in adapter (`"mongo"` | `"sqlite"` | `"memory"`).
292
439
 
@@ -367,7 +514,7 @@ The adapter is responsible for:
367
514
 
368
515
  Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
369
516
 
370
- ### Auth Rate Limiting
517
+ #### Auth Rate Limiting
371
518
 
372
519
  All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
373
520
 
@@ -377,7 +524,7 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
377
524
  | `POST /auth/register` | IP address | Every attempt | 5 / hour |
378
525
  | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
379
526
  | `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
380
- | `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
527
+ | `POST /auth/forgot-password` | IP address **and** email address | Every attempt | 5 / 15 min (shared window per key) |
381
528
  | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
382
529
  | `POST /auth/refresh` | IP address | Every attempt | 30 / min |
383
530
  | `POST /auth/mfa/verify` | IP address | Every attempt | 10 / 15 min |
@@ -385,7 +532,7 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
385
532
 
386
533
  Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
387
534
 
388
- #### Tuning limits
535
+ ##### Tuning limits
389
536
 
390
537
  ```ts
391
538
  await createServer({
@@ -401,7 +548,7 @@ await createServer({
401
548
  });
402
549
  ```
403
550
 
404
- #### Manually clearing a limit (admin unlock)
551
+ ##### Manually clearing a limit (admin unlock)
405
552
 
406
553
  If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
407
554
 
@@ -416,9 +563,9 @@ router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) =>
416
563
  });
417
564
  ```
418
565
 
419
- Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{userId}`.
566
+ Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{identifier}`, `forgot:ip:{ip}`, `forgot:email:{email}`, `reset:{ip}`, `refresh:ip:{ip}`, `deleteaccount:{userId}`.
420
567
 
421
- #### Using the rate limiter in your own routes
568
+ ##### Using the rate limiter in your own routes
422
569
 
423
570
  `trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
424
571
 
@@ -452,17 +599,17 @@ if (await isLimited(key, opts)) {
452
599
 
453
600
  Keys are automatically namespaced to the app (e.g. `rl:MyApp:submit:1.2.3.4`) when the Redis store is active, so they won't collide on a shared Redis instance.
454
601
 
455
- #### Store
602
+ ##### Store
456
603
 
457
604
  The rate limit store defaults to `"redis"` when Redis is enabled (recommended for multi-instance deployments — limits are shared across all servers). Falls back to `"memory"` automatically when Redis is disabled. In-memory limits don't persist across restarts.
458
605
 
459
606
  ---
460
607
 
461
- ### Bot Protection
608
+ #### Bot Protection
462
609
 
463
610
  The built-in IP rate limiter is ineffective against bots that rotate IPs. The `botProtection` config adds two IP-rotation-resistant layers that run before the IP rate limit check.
464
611
 
465
- #### Fingerprint rate limiting
612
+ ##### Fingerprint rate limiting
466
613
 
467
614
  When `fingerprintRateLimit: true`, every request is also rate-limited by an HTTP fingerprint — a 12-char hash derived from `User-Agent`, `Accept-*`, `Connection`, and the presence/absence of browser-only headers (`sec-fetch-*`, `sec-ch-ua-*`, `origin`, `referer`, etc.).
468
615
 
@@ -481,7 +628,7 @@ await createServer({
481
628
 
482
629
  The fingerprint bucket uses the same window and max as `security.rateLimit`, and is stored in the same backend as `auth.rateLimit.store` (Redis by default, shared across all instances).
483
630
 
484
- #### IP / CIDR blocklist
631
+ ##### IP / CIDR blocklist
485
632
 
486
633
  Block known datacenter ranges, proxy providers, or individual IPs outright. Matched requests receive a 403 before any other processing — no session lookup, no rate-limit increment.
487
634
 
@@ -501,7 +648,7 @@ await createServer({
501
648
 
502
649
  Both options can be combined. The middleware order is: blocklist → IP rate limit → fingerprint rate limit.
503
650
 
504
- #### Apply `botProtection` to individual routes
651
+ ##### Apply `botProtection` to individual routes
505
652
 
506
653
  `botProtection` is also exported for per-route use:
507
654
 
@@ -513,7 +660,7 @@ router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
513
660
 
514
661
  ---
515
662
 
516
- ### Trusted Proxy
663
+ #### Trusted Proxy
517
664
 
518
665
  By default, Bunshot uses the socket-level IP address for all rate limiting and session metadata — the `X-Forwarded-For` header is **ignored entirely**. This prevents attackers from spoofing IPs to bypass rate limits.
519
666
 
@@ -542,7 +689,7 @@ router.post("/api/action", async (c) => {
542
689
  });
543
690
  ```
544
691
 
545
- ### JWT Secret Validation
692
+ #### JWT Secret Validation
546
693
 
547
694
  JWT secrets are validated on first use. The framework throws a clear error if:
548
695
  - The environment variable (`JWT_SECRET_DEV` or `JWT_SECRET_PROD`) is missing
@@ -556,21 +703,30 @@ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
556
703
 
557
704
  ---
558
705
 
559
- ### Setting a password after social login
706
+ #### Setting a password after social login
560
707
 
561
708
  If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
562
709
 
563
710
  ```ts
564
- // Client (logged-in user)
711
+ // Client (logged-in user) — first time setting a password (no currentPassword needed)
565
712
  await fetch("/auth/set-password", {
566
713
  method: "POST",
567
714
  headers: { "Content-Type": "application/json", "x-user-token": token },
568
715
  body: JSON.stringify({ password: "mynewpassword" }),
569
716
  });
717
+
718
+ // Changing an existing password — currentPassword is required
719
+ await fetch("/auth/set-password", {
720
+ method: "POST",
721
+ headers: { "Content-Type": "application/json", "x-user-token": token },
722
+ body: JSON.stringify({ password: "mynewpassword", currentPassword: "oldpassword" }),
723
+ });
570
724
  ```
571
725
 
572
726
  The built-in route hashes the password and calls `adapter.setPassword(userId, hash)`. If your adapter does not implement `setPassword`, the route returns `501 Not Implemented`.
573
727
 
728
+ **If the account already has a password set**, `currentPassword` must be provided. Missing it returns `400 { error: "Current password is required to change an existing password." }`. An incorrect `currentPassword` returns `401 { error: "Current password is incorrect." }`.
729
+
574
730
  To support it with a custom adapter:
575
731
 
576
732
  ```ts
@@ -583,7 +739,7 @@ const myAdapter: AuthAdapter = {
583
739
  };
584
740
  ```
585
741
 
586
- ### CSRF Protection
742
+ #### CSRF Protection
587
743
 
588
744
  Opt-in via `security.csrf` — protects cookie-authenticated browser clients against cross-site request forgery attacks. Mobile apps and SPAs using header-based auth (`x-user-token`) are not affected and do not need CSRF.
589
745
 
@@ -1,10 +1,10 @@
1
- ## Auth Flow
1
+ ### Auth Flow
2
2
 
3
3
  Sessions are backed by Redis (default), MongoDB, SQLite, or memory. Each login creates an independent session keyed by UUID, so multiple devices stay logged in simultaneously.
4
4
 
5
5
  - **Browser clients**: `POST /auth/login` sets an HttpOnly cookie automatically
6
6
  - **API clients**: Read `token` from the response body, send `x-user-token: <token>` header
7
7
 
8
- Features include session management (list/revoke), refresh tokens (short-lived access + long-lived refresh with rotation), MFA (TOTP via Google Authenticator, email OTP, recovery codes), account deletion (immediate or queued with grace period), custom auth adapters, rate limiting on all auth endpoints, bot protection (fingerprint rate limiting + CIDR blocklist), and password set/reset flows.
8
+ Features include session management (list/revoke), current user endpoint (`GET /auth/me`), email verification (opt-in, with `required` gate), password reset (fire-and-forget, dual-keyed rate limiting), refresh tokens (short-lived access + long-lived refresh with rotation), MFA (TOTP via Google Authenticator, email OTP, WebAuthn, recovery codes), account deletion (immediate or queued BullMQ with grace period and cancel endpoint), custom auth adapters, rate limiting on all auth endpoints, bot protection (fingerprint rate limiting + CIDR blocklist), configurable primary field (email/username/phone), and CSRF protection.
9
9
 
10
10
  Protect routes with `userAuth`, `requireRole("admin")`, and `requireVerifiedEmail` middleware.