@lenne.tech/nest-server 11.35.1 → 11.36.1

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 (51) hide show
  1. package/.claude/rules/configurable-features.md +3 -0
  2. package/CLAUDE.md +1 -0
  3. package/FRAMEWORK-API.md +3 -2
  4. package/dist/core/common/helpers/logging.helper.js +1 -1
  5. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  7. package/dist/core/common/services/brevo.service.d.ts +1 -0
  8. package/dist/core/common/services/brevo.service.js +10 -2
  9. package/dist/core/common/services/brevo.service.js.map +1 -1
  10. package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
  11. package/dist/core/modules/better-auth/better-auth.config.js +35 -2
  12. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
  14. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
  15. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
  18. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.controller.js +19 -0
  20. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  21. package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
  22. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
  24. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  25. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  26. package/dist/core/modules/tenant/core-tenant.service.js +11 -2
  27. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  28. package/dist/templates/password-reset-de.ejs +65 -0
  29. package/dist/templates/password-reset-en.ejs +65 -0
  30. package/dist/templates/password-reset.ejs +1 -1
  31. package/dist/tsconfig.build.tsbuildinfo +1 -1
  32. package/docs/security-overrides.md +17 -11
  33. package/migration-guides/11.35.1-to-11.36.0.md +168 -0
  34. package/migration-guides/11.36.0-to-11.36.1.md +283 -0
  35. package/package.json +25 -25
  36. package/src/core/common/helpers/logging.helper.ts +9 -1
  37. package/src/core/common/interfaces/server-options.interface.ts +68 -2
  38. package/src/core/common/services/brevo.service.ts +34 -6
  39. package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
  40. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
  41. package/src/core/modules/better-auth/README.md +100 -15
  42. package/src/core/modules/better-auth/better-auth.config.ts +142 -9
  43. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
  44. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
  45. package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
  46. package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
  47. package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
  48. package/src/core/modules/tenant/core-tenant.service.ts +41 -4
  49. package/src/templates/password-reset-de.ejs +65 -0
  50. package/src/templates/password-reset-en.ejs +65 -0
  51. package/src/templates/password-reset.ejs +1 -1
@@ -361,6 +361,23 @@ export interface IBetterAuthEmailVerificationConfig {
361
361
  */
362
362
  locale?: string;
363
363
 
364
+ /**
365
+ * Brevo transactional template ID for the PASSWORD-RESET mail.
366
+ *
367
+ * Separate from `brevoTemplateId` on purpose: that one is the verification
368
+ * mail, and reusing it would send "confirm your email address" to someone who
369
+ * asked to reset their password. When this is unset the reset mail goes out
370
+ * over SMTP with the `password-reset[-<locale>].ejs` template instead.
371
+ *
372
+ * Template variables passed to Brevo:
373
+ * - `name`: User display name
374
+ * - `link`: Reset URL
375
+ * - `appName`: Application name
376
+ *
377
+ * @default undefined (uses SMTP/EJS templates)
378
+ */
379
+ passwordResetBrevoTemplateId?: number;
380
+
364
381
  /**
365
382
  * Cooldown in seconds between resend requests for the same email address.
366
383
  * Prevents abuse by limiting how often verification emails can be resent.
@@ -543,8 +560,13 @@ export interface IBetterAuthPasskeyConfig {
543
560
  */
544
561
  export interface IBetterAuthRateLimit {
545
562
  /**
546
- * Whether rate limiting is enabled
547
- * @default false
563
+ * Whether rate limiting is enabled.
564
+ *
565
+ * Follows the "presence implies enabled" pattern: providing a `rateLimit` object at all — even
566
+ * `{}` — turns the limiter ON, and only an explicit `enabled: false` keeps it off while letting
567
+ * you pre-configure the rest. Omitting `rateLimit` entirely leaves it off.
568
+ *
569
+ * @default true when a `rateLimit` object is present, false when it is absent
548
570
  */
549
571
  enabled?: boolean;
550
572
 
@@ -3414,6 +3436,50 @@ interface IBetterAuthBase {
3414
3436
  * @default true
3415
3437
  */
3416
3438
  enabled?: boolean;
3439
+
3440
+ /**
3441
+ * Whether Better-Auth's native password-reset flow is available.
3442
+ *
3443
+ * `CoreBetterAuthModule` wires the `sendResetPassword` hook automatically, and the presence of
3444
+ * that hook is what Better-Auth treats as the on switch — so the flow is ON out of the box and
3445
+ * `POST /iam/request-password-reset` mints a token and sends mail.
3446
+ *
3447
+ * Set `false` to withhold the hook, which makes that route answer `RESET_PASSWORD_DISABLED`
3448
+ * again. For deployments whose reset policy is support-mediated or SSO-primary, and which
3449
+ * therefore do not want an unauthenticated, token-minting, mail-sending endpoint at all.
3450
+ *
3451
+ * @default true
3452
+ * @since 11.36.1
3453
+ */
3454
+ passwordReset?: boolean;
3455
+
3456
+ /**
3457
+ * End every existing session when the user completes a password reset.
3458
+ *
3459
+ * A reset is what somebody reaches for when they suspect their account was
3460
+ * taken over, so leaving the older sessions alive defeats the point: the
3461
+ * attacker keeps theirs and the new password changes nothing for them.
3462
+ *
3463
+ * Left off by default because it is a behaviour change for existing
3464
+ * deployments — a reset then signs the user out everywhere, including on
3465
+ * the devices they still hold.
3466
+ *
3467
+ * Passed through to better-auth's native
3468
+ * `emailAndPassword.revokeSessionsOnPasswordReset`. Prefer this named field
3469
+ * over `options.emailAndPassword`: it is validated (`=== true`, so a JSON
3470
+ * env string cannot enable a sign-out-everywhere behaviour), typed and
3471
+ * discoverable.
3472
+ *
3473
+ * Until 11.36.1 the reason was harsher — `options` was spread SHALLOWLY, so
3474
+ * an `options.emailAndPassword` replaced the whole block including the
3475
+ * scrypt `password.hash` / `password.verify` pair, and every credential in
3476
+ * the database stopped verifying. `emailAndPassword` is deep-merged now,
3477
+ * with `password` re-applied as the base, so that trap is closed.
3478
+ *
3479
+ * @default false
3480
+ * @since 11.36.0
3481
+ */
3482
+ revokeSessionsOnPasswordReset?: boolean;
3417
3483
  };
3418
3484
 
3419
3485
  /**
@@ -53,9 +53,8 @@ export class BrevoService {
53
53
  return false;
54
54
  }
55
55
 
56
- // Exclude (test) users, must be done via config and not via configFastButReadOnly,
57
- // otherwise the error TypeError: Cannot assign to read only property 'lastIndex' of object '[object RegExp]' occurs
58
- if (this.configService.config?.brevo?.exclude?.test?.(to)) {
56
+ // Exclude (test) users
57
+ if (this.isExcluded(to)) {
59
58
  return 'TEST_USER!';
60
59
  }
61
60
 
@@ -98,9 +97,8 @@ export class BrevoService {
98
97
  return false;
99
98
  }
100
99
 
101
- // Exclude (test) users, must be done via config and not via configFastButReadOnly,
102
- // otherwise the error TypeError: Cannot assign to read only property 'lastIndex' of object '[object RegExp]' occurs
103
- if (this.configService.config?.brevo?.exclude?.test?.(to)) {
100
+ // Exclude (test) users
101
+ if (this.isExcluded(to)) {
104
102
  return 'TEST_USER!';
105
103
  }
106
104
 
@@ -135,6 +133,36 @@ export class BrevoService {
135
133
  return { 'Idempotency-Key': randomUUID() };
136
134
  }
137
135
 
136
+ /**
137
+ * Checks a recipient against `brevo.exclude` without inheriting the pattern's match state.
138
+ *
139
+ * Two traps live in this one line, and both have bitten:
140
+ *
141
+ * 1. `RegExp.prototype.test` ADVANCES `lastIndex` on a pattern carrying `g` or `y`. The config
142
+ * holds a single shared instance and projects declare it as `/…/gi`, so calling `.test()` on
143
+ * it directly answers true, false, true, … for the very same address — every second excluded
144
+ * recipient receives a real mail. Matching against a flagless copy keeps each call
145
+ * independent and leaves the configured pattern untouched.
146
+ * 2. It must be read from `config`, never from `configFastButReadOnly`: assigning `lastIndex` on
147
+ * the frozen copy throws `TypeError: Cannot assign to read only property 'lastIndex'`.
148
+ * Point 1 removes the assignment, but the frozen object may still be a `deepFreeze`d clone
149
+ * whose flags differ, so the mutable side stays the source of truth.
150
+ *
151
+ * @param to - Recipient email address
152
+ * @returns `true` when the recipient matches the configured exclude pattern
153
+ */
154
+ protected isExcluded(to: string): boolean {
155
+ const exclude = this.configService.config?.brevo?.exclude;
156
+ if (typeof exclude?.test !== 'function') {
157
+ return false;
158
+ }
159
+
160
+ const stateless =
161
+ exclude.global || exclude.sticky ? new RegExp(exclude.source, exclude.flags.replace(/[gy]/g, '')) : exclude;
162
+
163
+ return stateless.test(to);
164
+ }
165
+
138
166
  /**
139
167
  * Lazily constructs (and memoises) the Brevo SDK client.
140
168
  *
@@ -295,9 +295,27 @@ export class CustomEmailVerificationService extends CoreBetterAuthEmailVerificat
295
295
  await super.sendVerificationEmail(options);
296
296
  // Custom logic after (e.g., analytics)
297
297
  }
298
+
299
+ // The password-reset mail is a separate override point with the same shape. Better-Auth calls it
300
+ // through the `emailAndPassword.sendResetPassword` hook CoreBetterAuthModule injects.
301
+ override async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
302
+ // e.g. supply a logo for the shipped templates, which render an <img> when `logoSrc` is set
303
+ await super.sendPasswordResetEmail(options);
304
+ }
305
+
306
+ // Subject lines are separate protected hooks on both flows.
307
+ protected override getPasswordResetSubject(appName: string): string {
308
+ return `${appName} — choose a new password`;
309
+ }
298
310
  }
299
311
  ```
300
312
 
313
+ **Note:** `sendPasswordResetEmail()` re-throws a Brevo or SMTP send failure after logging it with
314
+ the masked address. The framework's own caller wraps it in `sendAuthEmailSafely`, so a throw never
315
+ reaches the request — an override that calls it directly must handle it. It is also throttled per
316
+ recipient address (see `resendCooldownSeconds`); a send that fails releases the slot so the
317
+ locked-out user may retry immediately.
318
+
301
319
  ### CoreBetterAuthUserMapper
302
320
 
303
321
  Handles user mapping between BetterAuth and nest-server User model. Extend when you need to:
@@ -324,7 +342,7 @@ Email templates are resolved in this order:
324
342
  | Template | Purpose | Default Locales |
325
343
  | -------------------- | ----------------------------------- | --------------- |
326
344
  | `email-verification` | Email verification after sign-up | `en`, `de` |
327
- | `password-reset` | Password reset email | `en` |
345
+ | `password-reset` | Password reset email | `en`, `de` |
328
346
  | `welcome` | Welcome email (not used by default) | `en` |
329
347
 
330
348
  ### How to Override Templates
@@ -357,12 +375,20 @@ const config = {
357
375
 
358
376
  Available variables in email templates:
359
377
 
360
- | Variable | Type | Description |
361
- | ----------- | ------ | ------------------------------------------------- |
362
- | `name` | string | User's name or email prefix |
363
- | `link` | string | Verification/reset URL |
364
- | `appName` | string | Application name from package.json |
365
- | `expiresIn` | string | Human-readable expiration time (e.g., "24 hours") |
378
+ | Variable | Type | Passed by | Description |
379
+ | ----------- | ------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
380
+ | `name` | string | both flows | User's name or email prefix |
381
+ | `link` | string | both flows | Verification/reset URL |
382
+ | `appName` | string | both flows | Application name from package.json |
383
+ | `expiresIn` | string | verification only | Human-readable expiration time (e.g., "24 hours") |
384
+ | `logoSrc` | string | **nobody — optional** | Not supplied by the framework. The shipped password-reset templates render an `<img>` when it is present and the app name as text otherwise; pass it from a subclass override to use it. |
385
+
386
+ **A template may only reference what its caller passes.** EJS resolves variables at render time, so
387
+ a missing one is a `ReferenceError` and an HTTP 500, not a build error. Two shapes reach
388
+ `password-reset`: the IAM flow passes `{ name, link, appName }`, and the LEGACY
389
+ `POST /users/password/reset-request` flow (`UserService.sendPasswordResetMail()`) passes
390
+ `{ name, link }` only. Guard anything else with `typeof x !== 'undefined'`, as the shipped
391
+ templates do.
366
392
 
367
393
  ### Example Template
368
394
 
@@ -270,6 +270,44 @@ Available variables: `name`, `link`, `expiresIn`, `appName`
270
270
 
271
271
  ---
272
272
 
273
+ ## Password Reset (v11.36.1+)
274
+
275
+ **Native password reset is ON by default and needs no configuration.**
276
+ `CoreBetterAuthModule` injects the `emailAndPassword.sendResetPassword` hook, so
277
+ `POST /iam/request-password-reset` is live from the first boot. Before 11.36.1 no hook was wired
278
+ and the route answered `RESET_PASSWORD_DISABLED`, so **this changes behaviour on upgrade**: reset
279
+ mail starts going out to real users.
280
+
281
+ Check these four things when integrating:
282
+
283
+ - [ ] **Rate limiting is on.** The route is unauthenticated and sends mail. `betterAuth.rateLimit`
284
+ is off unless you configure it — providing the object at all (even `{}`) enables it. The
285
+ mailer additionally holds a per-ADDRESS cooldown (`emailVerification.resendCooldownSeconds`,
286
+ 60 s), which is the axis that matters against mail-bombing one victim from rotating IPs.
287
+ - [ ] **Your reset page exists and points at `POST /iam/reset-password`** with `{ token, newPassword }`.
288
+ Tokens are valid for 1 h by default.
289
+ - [ ] **`trustedOrigins` contains no wildcard.** `redirectTo` is validated against it and the reset
290
+ redirect carries the token, so a wildcard hands a live token to any origin it admits. The
291
+ framework warns at boot if it finds one.
292
+ - [ ] **If you ship your own `password-reset.ejs`, it must render from `{ link, name }` alone.**
293
+ The legacy `POST /users/password/reset-request` flow resolves that exact template name and
294
+ passes nothing else. Locale variants (`password-reset-de.ejs` / `-en.ejs`) are only reached
295
+ by the IAM flow and may use `appName`.
296
+
297
+ To turn the flow off entirely (support-mediated or SSO-primary reset policies):
298
+
299
+ ```typescript
300
+ betterAuth: {
301
+ emailAndPassword: { passwordReset: false },
302
+ },
303
+ ```
304
+
305
+ Optional: `betterAuth.emailVerification.passwordResetBrevoTemplateId` routes the mail through
306
+ Brevo. It deliberately does **not** fall back to `brevoTemplateId` — that is the verification
307
+ template, which would tell the user to confirm their address instead of resetting their password.
308
+
309
+ ---
310
+
273
311
  ## Sign-Up Checks (v11.13.0+)
274
312
 
275
313
  Sign-up validation is **enabled by default** requiring `termsAndPrivacyAccepted`.
@@ -1732,31 +1732,116 @@ When a legacy user signs in via BetterAuth for the first time, their account is
1732
1732
 
1733
1733
  ### BetterAuth Password Reset Configuration
1734
1734
 
1735
- BetterAuth provides native password reset via `/iam/forgot-password` and `/iam/reset-password` endpoints. To enable this, configure the `sendResetPassword` callback:
1735
+ BetterAuth provides native password reset via `/iam/request-password-reset` and
1736
+ `/iam/reset-password`. It is **enabled automatically** — `CoreBetterAuthModule`
1737
+ injects a `sendResetPassword` hook that delegates to
1738
+ `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`. Nothing needs
1739
+ to be configured for the flow to work; without that hook Better-Auth answers
1740
+ `RESET_PASSWORD_DISABLED`.
1741
+
1742
+ > **This is a live, unauthenticated endpoint on every deployment.** Anyone can
1743
+ > POST any address to it and cause a mail to be sent and a token to be minted.
1744
+ > Before you ship, check the two bounds below (rate limit + cooldown), or turn
1745
+ > the flow off if your reset policy is support-mediated or SSO-primary:
1746
+ >
1747
+ > ```typescript
1748
+ > betterAuth: {
1749
+ > emailAndPassword: { passwordReset: false }, // route answers RESET_PASSWORD_DISABLED again
1750
+ > },
1751
+ > ```
1752
+
1753
+ #### Rate limiting (check this)
1754
+
1755
+ Two independent bounds, on different axes:
1756
+
1757
+ | Bound | Axis | Default |
1758
+ | ----------------------------------------------------------- | ----------------- | ------------------------- |
1759
+ | `betterAuth.rateLimit` (route middleware) | IP | **off unless configured** |
1760
+ | Mailer cooldown (`emailVerification.resendCooldownSeconds`) | recipient address | 60 s |
1761
+
1762
+ The recipient-axis cooldown is what stops an attacker rotating IPs to mail-bomb
1763
+ one victim; the IP-axis limiter is what stops one caller hammering the route.
1764
+ `/request-password-reset` is listed in `strictEndpoints`, so it receives the
1765
+ halved limit when the limiter is on. Providing a `rateLimit` object at all —
1766
+ even `{}` — enables it:
1767
+
1768
+ ```typescript
1769
+ betterAuth: {
1770
+ rateLimit: { max: 10, windowSeconds: 60 },
1771
+ },
1772
+ ```
1773
+
1774
+ #### Branding the mail
1775
+
1776
+ Drop your own template into the project template directory — it wins over the
1777
+ nest-server default via the usual project-first, locale-aware lookup:
1778
+
1779
+ ```
1780
+ src/assets/templates/password-reset-de.ejs # or -en, or plain password-reset.ejs
1781
+ ```
1782
+
1783
+ | Variable | Always passed | Notes |
1784
+ | --------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1785
+ | `name` | yes | `user.name`, falling back to the local part of the address |
1786
+ | `link` | yes | the ready-made reset URL |
1787
+ | `appName` | yes | derived from `package.json` `name` |
1788
+ | `logoSrc` | **no** | optional. The shipped templates render an `<img>` when it is present and the app name as text otherwise. Nothing in the framework supplies it — pass it from a subclass that overrides `sendPasswordResetEmail()`, or reference it from your own template. |
1789
+
1790
+ > **Keep the un-suffixed `password-reset.ejs` renderable from `{ link, name }` alone.**
1791
+ > The legacy `POST /users/password/reset-request` flow (`UserService.sendPasswordResetMail()`)
1792
+ > resolves that exact name and passes nothing else, so a template that
1793
+ > references `appName` unguarded turns legacy password recovery into an HTTP 500.
1794
+ > The framework's own shipped templates guard it; `tests/unit/email-templates.spec.ts`
1795
+ > pins the contract.
1796
+
1797
+ For Brevo, set a **reset-specific** template id (never reuse `brevoTemplateId` —
1798
+ that is the verification mail, and the user would be told to confirm their
1799
+ address instead of resetting their password):
1736
1800
 
1737
1801
  ```typescript
1738
1802
  // config.env.ts
1739
1803
  betterAuth: {
1740
- options: {
1741
- emailAndPassword: {
1742
- sendResetPassword: async ({ user, url, token }) => {
1743
- // Send password reset email
1744
- // 'url' contains the full reset URL with token
1745
- await emailService.sendEmail({
1746
- to: user.email,
1747
- subject: 'Reset Your Password',
1748
- html: `<a href="${url}">Click here to reset your password</a>`,
1749
- });
1750
- },
1751
- },
1804
+ emailVerification: {
1805
+ passwordResetBrevoTemplateId: 42,
1752
1806
  },
1753
1807
  },
1754
1808
  ```
1755
1809
 
1810
+ A Brevo send that reports failure by resolving to `null` falls through to SMTP
1811
+ rather than returning, so an outage does not leave a locked-out user with no
1812
+ mail at all.
1813
+
1814
+ To replace the sending logic entirely, override `sendPasswordResetEmail()` in a
1815
+ subclass of `CoreBetterAuthEmailVerificationService`, exactly as with
1816
+ `sendVerificationEmail()`.
1817
+
1818
+ #### Why not `betterAuth.options.emailAndPassword`
1819
+
1820
+ You _can_ set `sendResetPassword` there, and it will win — `options` is merged
1821
+ last. That is precisely why it is the wrong place:
1822
+
1823
+ - **Your callback replaces the framework hook entirely.** The mail no longer
1824
+ goes through `sendPasswordResetEmail()` or the templates above, and it loses
1825
+ the fire-and-forget wrapper that keeps the response time from revealing
1826
+ whether the address exists.
1827
+ - **The block you are writing into carries the password hashing.** It holds
1828
+ `password: { hash: nativeScryptHash, verify: nativeScryptVerify }` — the
1829
+ hashing every stored credential was created with. Since 11.36.1 the merge
1830
+ protects `password` key-by-key, so an unrelated key can no longer drop it, but
1831
+ staying out of that block is still the safer habit.
1832
+
1833
+ Use the hook, the template, and `passwordReset: false` to opt out.
1834
+
1756
1835
  #### Password Reset Flow (BetterAuth)
1757
1836
 
1758
- 1. User requests reset: `POST /iam/forgot-password` with `{ email }`
1759
- 2. BetterAuth generates token and calls `sendResetPassword` callback
1837
+ 1. User requests reset: `POST /iam/request-password-reset` with `{ email }` and
1838
+ an optional `redirectTo`. `redirectTo` is validated against
1839
+ `trustedOrigins` — a value outside it is rejected. Do not put a wildcard in
1840
+ `trustedOrigins`: the reset redirect carries the token, so any origin the
1841
+ wildcard admits can collect a live one.
1842
+ 2. BetterAuth generates a token (valid 1 h by default,
1843
+ `resetPasswordTokenExpiresIn`) and calls the built-in `sendResetPassword`
1844
+ hook, which delegates to `sendPasswordResetEmail()`
1760
1845
  3. User clicks link in email → navigates to reset page
1761
1846
  4. Frontend submits: `POST /iam/reset-password` with `{ token, newPassword }`
1762
1847
  5. BetterAuth updates password in `account` collection
@@ -128,6 +128,18 @@ export interface CreateBetterAuthOptions {
128
128
  */
129
129
  onEmailVerified?: OnEmailVerifiedCallback;
130
130
 
131
+ /**
132
+ * Callback for sending the password-reset email.
133
+ * Injected from CoreBetterAuthModule to use NestJS services.
134
+ *
135
+ * When provided, it is wired into Better-Auth's
136
+ * `emailAndPassword.sendResetPassword` hook, which is what ENABLES the native
137
+ * `POST /iam/request-password-reset` flow. Without a callback Better-Auth
138
+ * answers `RESET_PASSWORD_DISABLED` and no reset mail is ever sent — a
139
+ * password-reset page that cannot work, and users locked out for good.
140
+ */
141
+ sendResetPasswordEmail?: SendResetPasswordEmailCallback;
142
+
131
143
  /**
132
144
  * Callback for sending verification email
133
145
  * Injected from CoreBetterAuthModule to use NestJS services
@@ -174,11 +186,35 @@ export type OnEmailVerifiedCallback = (userId: string) => Promise<void>;
174
186
  * Callback for sending verification email
175
187
  * Injected from CoreBetterAuthModule to use NestJS services
176
188
  */
177
- export type SendVerificationEmailCallback = (options: {
189
+ export interface AuthEmailCallbackOptions {
190
+ /** The raw token, for consumers that build their own link. */
178
191
  token: string;
192
+
193
+ /** The ready-made URL Better-Auth generated. */
179
194
  url: string;
195
+
196
+ /** The user the mail is for. */
180
197
  user: { email: string; id: string; name?: null | string };
181
- }) => Promise<void>;
198
+ }
199
+
200
+ export type SendVerificationEmailCallback = (options: AuthEmailCallbackOptions) => Promise<void>;
201
+
202
+ /**
203
+ * Sends the password-reset mail. Same shape as the verification callback —
204
+ * `url` is the ready-made reset link Better-Auth generated, `token` the raw
205
+ * token for consumers that build their own link.
206
+ */
207
+ /**
208
+ * Sends the password-reset mail.
209
+ *
210
+ * Naming note — the word order flips at this boundary, once and deliberately. Everything on the
211
+ * Better-Auth side is `resetPassword` because Better-Auth's own option key is `sendResetPassword`;
212
+ * everything on the NestJS service side is `passwordReset`
213
+ * (`CoreBetterAuthEmailVerificationService.sendPasswordResetEmail`, `PASSWORD_RESET_TEMPLATE`,
214
+ * `passwordResetBrevoTemplateId`) because that is how the feature is named in this framework's
215
+ * config and templates. If you grep for one spelling and find half the feature, this is why.
216
+ */
217
+ export type SendResetPasswordEmailCallback = (options: AuthEmailCallbackOptions) => Promise<void>;
182
218
 
183
219
  /**
184
220
  * Invoke an auth-email send (verification / password-reset) fire-and-forget.
@@ -192,6 +228,12 @@ export type SendVerificationEmailCallback = (options: {
192
228
  * rejection) to `onError` instead. `onError` itself is guarded too: if the
193
229
  * handler throws, the error is swallowed rather than crashing the process.
194
230
  *
231
+ * Do NOT remove this wrapper on the grounds that Better-Auth "already detaches". Verified against
232
+ * better-auth 1.6.26 (`context/create-context.mjs:214`): `runInBackgroundOrAwait` detaches ONLY when
233
+ * `advanced.backgroundTasks.handler` is configured, and `else await promise`. This framework does
234
+ * not configure a handler, so without this wrapper Better-Auth would await the send — and the
235
+ * response time would once again reveal whether the address exists.
236
+ *
195
237
  * Deliberate divergence from Better-Auth's own mechanism: Better-Auth awaits
196
238
  * these callbacks via `runInBackgroundOrAwait` and offers
197
239
  * `advanced.backgroundTasks` as its native non-blocking path. That option is
@@ -214,6 +256,23 @@ export function sendAuthEmailSafely(send: () => unknown, onError: (error: unknow
214
256
  });
215
257
  }
216
258
 
259
+ /**
260
+ * Shallow-merge `override` onto `base`, skipping keys whose override value is `undefined`.
261
+ *
262
+ * Distinct from a spread on purpose — see the call site in the `emailAndPassword` merge.
263
+ */
264
+ function mergeDefined(base: unknown, override: unknown): Record<string, unknown> {
265
+ const merged: Record<string, unknown> = { ...(base as Record<string, unknown> | undefined) };
266
+ if (override && typeof override === 'object') {
267
+ for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
268
+ if (value !== undefined) {
269
+ merged[key] = value;
270
+ }
271
+ }
272
+ }
273
+ return merged;
274
+ }
275
+
217
276
  /**
218
277
  * Better-Auth field type definition
219
278
  * Matches the DBFieldType from better-auth
@@ -312,7 +371,8 @@ export interface CreateBetterAuthResult {
312
371
  }
313
372
 
314
373
  export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
315
- const { config, db, fallbackSecrets, onEmailVerified, sendVerificationEmail, serverEnv } = options;
374
+ const { config, db, fallbackSecrets, onEmailVerified, sendResetPasswordEmail, sendVerificationEmail, serverEnv } =
375
+ options;
316
376
 
317
377
  // Return null only if better-auth is explicitly disabled
318
378
  // BetterAuth is enabled by default (zero-config)
@@ -444,6 +504,36 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
444
504
  hash: nativeScryptHash,
445
505
  verify: nativeScryptVerify,
446
506
  },
507
+ // Opt-in: a reset then also ends the sessions that already existed, which
508
+ // is the point of resetting after a suspected takeover. Off by default so
509
+ // the behaviour of existing deployments does not change under them.
510
+ revokeSessionsOnPasswordReset: config.emailAndPassword?.revokeSessionsOnPasswordReset === true,
511
+ // Presence of this hook is what turns the native password reset ON, so withholding it is the
512
+ // only way to keep `POST /iam/request-password-reset` answering RESET_PASSWORD_DISABLED.
513
+ //
514
+ // Note what this condition does NOT buy: `CoreBetterAuthModule.createEmailVerificationCallbacks()`
515
+ // always supplies a callback — the "no mail service" case is handled INSIDE it — so for every
516
+ // consumer that goes through the module, the callback half is always true. An earlier comment
517
+ // here claimed a mail-less server would keep answering RESET_PASSWORD_DISABLED; it would not.
518
+ // `emailAndPassword.passwordReset: false` is the real off switch, for deployments whose reset
519
+ // policy is support-mediated or SSO-primary.
520
+ // Mirrors the emailVerification.sendVerificationEmail wiring.
521
+ ...(sendResetPasswordEmail &&
522
+ config.emailAndPassword?.passwordReset !== false && {
523
+ sendResetPassword: async (data: AuthEmailCallbackOptions) => {
524
+ // Deliberately NOT awaited: Better-Auth answers the request the same
525
+ // way whether or not the address exists, and awaiting the send would
526
+ // leak that difference as response time. A failed send must still be
527
+ // logged rather than crash the process — see sendAuthEmailSafely.
528
+ sendAuthEmailSafely(
529
+ () => sendResetPasswordEmail(data),
530
+ (error) =>
531
+ logger.error(
532
+ `Failed to send password-reset email: ${error instanceof Error ? error.message : String(error)}`,
533
+ ),
534
+ );
535
+ },
536
+ }),
447
537
  },
448
538
  plugins,
449
539
  secret: validation.resolvedSecret || config.secret,
@@ -463,15 +553,61 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
463
553
  // When undefined, Better-Auth uses its default CORS behavior (allows all origins)
464
554
  if (trustedOrigins) {
465
555
  betterAuthConfig.trustedOrigins = trustedOrigins;
556
+
557
+ // A wildcard here was a CORS-looseness problem before the password-reset flow was wired; it is
558
+ // an account-takeover vector now. `redirectTo` on POST /iam/request-password-reset is validated
559
+ // against trustedOrigins, and GET /iam/reset-password/:token redirects to
560
+ // `<callbackURL>?token=<token>` — so a wildcard that admits an attacker-controlled subdomain
561
+ // hands them a live reset token for whichever address they named.
562
+ const wildcardOrigins = trustedOrigins.filter((origin) => typeof origin === 'string' && origin.includes('*'));
563
+ if (wildcardOrigins.length) {
564
+ logger.warn(
565
+ `betterAuth.trustedOrigins contains a wildcard (${wildcardOrigins.join(', ')}). The password-reset ` +
566
+ 'redirect is validated against this list, so any origin it admits can receive a live reset ' +
567
+ 'token. List exact origins instead.',
568
+ );
569
+ }
466
570
  }
467
571
 
468
572
  // Merge with custom options passthrough
469
573
  // This allows projects to configure any Better-Auth option not explicitly defined
470
- // Deep-merge 'advanced' to preserve cookiePrefix when options.advanced is provided
574
+ // Deep-merge 'advanced' to preserve cookiePrefix when options.advanced is provided,
575
+ // and 'emailAndPassword' to preserve the password hashing (see below).
471
576
  let finalConfig: Record<string, unknown>;
472
577
  if (config.options) {
473
- const { advanced: optionsAdvanced, ...restOptions } = config.options as Record<string, unknown>;
578
+ const {
579
+ advanced: optionsAdvanced,
580
+ emailAndPassword: optionsEmailAndPassword,
581
+ ...restOptions
582
+ } = config.options as Record<string, unknown>;
474
583
  finalConfig = { ...betterAuthConfig, ...restOptions };
584
+
585
+ // `emailAndPassword` needs a DEEP merge, and this one is load-bearing.
586
+ // The block above carries `password: { hash: nativeScryptHash, verify:
587
+ // nativeScryptVerify }` — the hashing every stored credential was created
588
+ // with. A shallow spread would let a consumer who sets `options.
589
+ // emailAndPassword` for an unrelated reason (a `sendResetPassword` callback,
590
+ // `maxPasswordLength`) replace the whole block and drop those functions.
591
+ // Better-Auth would fall back to its own default hasher, every existing
592
+ // password would stop verifying, and every user of that deployment would be
593
+ // locked out at once — with nothing in the logs to say why.
594
+ // `password` is therefore re-applied as the BASE, so an explicit override
595
+ // still wins while an unrelated key can no longer clobber it.
596
+ if (optionsEmailAndPassword && typeof optionsEmailAndPassword === 'object') {
597
+ const base = betterAuthConfig.emailAndPassword as Record<string, unknown>;
598
+ const override = optionsEmailAndPassword as Record<string, unknown>;
599
+ finalConfig.emailAndPassword = {
600
+ ...base,
601
+ ...override,
602
+ // Merged key-by-key rather than spread, because object spread copies an EXPLICITLY
603
+ // undefined value as a present key: `password: { hash: undefined }` would survive as
604
+ // `hash: undefined`, and Better-Auth resolves `password?.hash || hashPassword`, silently
605
+ // switching to its own hasher for WRITES while nest-server's scrypt verify still handles
606
+ // READS. The result is an asymmetric pair — anyone who resets their password can then
607
+ // never sign in again. An explicit override still wins; only `undefined` is ignored.
608
+ password: mergeDefined(base.password, override.password),
609
+ };
610
+ }
475
611
  if (optionsAdvanced && typeof optionsAdvanced === 'object') {
476
612
  // Drift guard: a programmatic `options.advanced.cookiePrefix` would only
477
613
  // change what Better-Auth itself sets — the NestJS layer keeps resolving
@@ -586,10 +722,7 @@ function buildEmailVerificationConfig(
586
722
 
587
723
  // Add sendVerificationEmail callback if provided
588
724
  if (sendVerificationEmail) {
589
- result.sendVerificationEmail = async (
590
- data: { token: string; url: string; user: { email: string; id: string; name?: null | string } },
591
- _request?: Request,
592
- ) => {
725
+ result.sendVerificationEmail = async (data: AuthEmailCallbackOptions, _request?: Request) => {
593
726
  // Fire-and-forget (timing-attack mitigation, per Better-Auth docs) — but a
594
727
  // failed send must be logged, never crash the process (see sendAuthEmailSafely).
595
728
  // Note: delivery failures are also logged (with masked recipient) by the