@lenne.tech/nest-server 11.36.0 → 11.36.2
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 -1
- package/.claude/rules/testing.md +24 -1
- 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 +2 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
- package/dist/core/modules/better-auth/better-auth.config.js +34 -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.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.36.0-to-11.36.1.md +283 -0
- package/migration-guides/11.36.1-to-11.36.2.md +67 -0
- package/package.json +10 -11
- package/src/core/common/helpers/logging.helper.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +50 -8
- 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 +138 -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.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
|
@@ -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)
|
|
@@ -448,6 +508,32 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
448
508
|
// is the point of resetting after a suspected takeover. Off by default so
|
|
449
509
|
// the behaviour of existing deployments does not change under them.
|
|
450
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
|
+
}),
|
|
451
537
|
},
|
|
452
538
|
plugins,
|
|
453
539
|
secret: validation.resolvedSecret || config.secret,
|
|
@@ -467,15 +553,61 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
467
553
|
// When undefined, Better-Auth uses its default CORS behavior (allows all origins)
|
|
468
554
|
if (trustedOrigins) {
|
|
469
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
|
+
}
|
|
470
570
|
}
|
|
471
571
|
|
|
472
572
|
// Merge with custom options passthrough
|
|
473
573
|
// This allows projects to configure any Better-Auth option not explicitly defined
|
|
474
|
-
// 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).
|
|
475
576
|
let finalConfig: Record<string, unknown>;
|
|
476
577
|
if (config.options) {
|
|
477
|
-
const {
|
|
578
|
+
const {
|
|
579
|
+
advanced: optionsAdvanced,
|
|
580
|
+
emailAndPassword: optionsEmailAndPassword,
|
|
581
|
+
...restOptions
|
|
582
|
+
} = config.options as Record<string, unknown>;
|
|
478
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
|
+
}
|
|
479
611
|
if (optionsAdvanced && typeof optionsAdvanced === 'object') {
|
|
480
612
|
// Drift guard: a programmatic `options.advanced.cookiePrefix` would only
|
|
481
613
|
// change what Better-Auth itself sets — the NestJS layer keeps resolving
|
|
@@ -590,10 +722,7 @@ function buildEmailVerificationConfig(
|
|
|
590
722
|
|
|
591
723
|
// Add sendVerificationEmail callback if provided
|
|
592
724
|
if (sendVerificationEmail) {
|
|
593
|
-
result.sendVerificationEmail = async (
|
|
594
|
-
data: { token: string; url: string; user: { email: string; id: string; name?: null | string } },
|
|
595
|
-
_request?: Request,
|
|
596
|
-
) => {
|
|
725
|
+
result.sendVerificationEmail = async (data: AuthEmailCallbackOptions, _request?: Request) => {
|
|
597
726
|
// Fire-and-forget (timing-attack mitigation, per Better-Auth docs) — but a
|
|
598
727
|
// failed send must be logged, never crash the process (see sendAuthEmailSafely).
|
|
599
728
|
// Note: delivery failures are also logged (with masked recipient) by the
|