@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.
- package/.claude/rules/configurable-features.md +3 -0
- package/CLAUDE.md +1 -0
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/helpers/logging.helper.js +1 -1
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
- package/dist/core/common/services/brevo.service.d.ts +1 -0
- package/dist/core/common/services/brevo.service.js +10 -2
- package/dist/core/common/services/brevo.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
- package/dist/core/modules/better-auth/better-auth.config.js +35 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +11 -2
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +65 -0
- package/dist/templates/password-reset-en.ejs +65 -0
- package/dist/templates/password-reset.ejs +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +17 -11
- package/migration-guides/11.35.1-to-11.36.0.md +168 -0
- package/migration-guides/11.36.0-to-11.36.1.md +283 -0
- package/package.json +25 -25
- package/src/core/common/helpers/logging.helper.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +68 -2
- package/src/core/common/services/brevo.service.ts +34 -6
- package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
- package/src/core/modules/better-auth/README.md +100 -15
- package/src/core/modules/better-auth/better-auth.config.ts +142 -9
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
- package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
- package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
- package/src/core/modules/tenant/core-tenant.service.ts +41 -4
- package/src/templates/password-reset-de.ejs +65 -0
- package/src/templates/password-reset-en.ejs +65 -0
- 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
|
-
*
|
|
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
|
|
57
|
-
|
|
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
|
|
102
|
-
|
|
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/
|
|
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
|
-
|
|
1741
|
-
|
|
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/
|
|
1759
|
-
|
|
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
|
|
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
|
-
}
|
|
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 } =
|
|
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 {
|
|
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
|