@lenne.tech/nest-server 11.38.0 → 11.40.0
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 +24 -1
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +117 -2
- package/.claude/rules/testing.md +238 -5
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +2 -2
- package/dist/config.env.js +4 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/config.helper.d.ts +2 -0
- package/dist/core/common/helpers/config.helper.js +18 -0
- package/dist/core/common/helpers/config.helper.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
- package/dist/core/common/helpers/cookies.helper.js +9 -0
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -0
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +6 -1
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
- package/dist/core/common/services/email.service.d.ts +4 -1
- package/dist/core/common/services/email.service.js +25 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/module.service.js +1 -0
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +7 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
- 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-error-codes.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +27 -0
- package/dist/core/modules/error-code/error-codes.js +24 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/core-hub.service.js +1 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -1
- package/dist/core/modules/user/core-user.model.d.ts +1 -0
- package/dist/core/modules/user/core-user.model.js +11 -1
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +9 -0
- package/dist/core/modules/user/core-user.service.js +96 -4
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.service.js +12 -6
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +12 -0
- package/dist/templates/password-reset-en.ejs +12 -0
- package/dist/templates/password-reset.ejs +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +14 -0
- package/migration-guides/11.37.x-to-11.38.x.md +18 -1
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/package.json +5 -4
- package/src/config.env.ts +19 -3
- package/src/core/common/helpers/config.helper.ts +79 -0
- package/src/core/common/helpers/cookies.helper.ts +38 -0
- package/src/core/common/helpers/input.helper.ts +37 -0
- package/src/core/common/helpers/service.helper.ts +9 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
- package/src/core/common/interfaces/server-options.interface.ts +120 -4
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/ai/README.md +33 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
- package/src/core/modules/better-auth/better-auth.config.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +55 -0
- package/src/core/modules/hub/core-hub.service.ts +1 -0
- package/src/core/modules/user/core-user.model.ts +28 -1
- package/src/core/modules/user/core-user.service.ts +267 -5
- package/src/server/modules/user/user.service.ts +26 -7
- package/src/templates/password-reset-de.ejs +12 -0
- package/src/templates/password-reset-en.ejs +12 -0
- package/src/templates/password-reset.ejs +1 -0
|
@@ -608,6 +608,20 @@ If authentication succeeds, `req.user` is set with the authenticated user (inclu
|
|
|
608
608
|
> A project subclassing the middleware must know this: the body it forwards is no longer
|
|
609
609
|
> byte-identical to the body it received. `normalizeResetPassword()` is `protected`.
|
|
610
610
|
|
|
611
|
+
> **Response side, since 11.38.0 — `wrapBetterAuthErrorResponse()`.** The mirror image of the note
|
|
612
|
+
> above. Immediately after `authInstance.handler()` returns, every **failed** response passes
|
|
613
|
+
> through a single choke point that rewrites its `message` to carry nest-server's `#LTNS_XXXX:`
|
|
614
|
+
> marker, so frontends can translate it instead of showing Better-Auth's English. The `code` field
|
|
615
|
+
> is left untouched, successful responses are returned **by identity** (the body is a single-read
|
|
616
|
+
> stream, so rebuilding one would break every later branch), and 3xx is skipped — Better-Auth
|
|
617
|
+
> reports some failures by redirecting with `?error=<CODE>`, and rewriting that query string would
|
|
618
|
+
> invent a second contract over its documented one.
|
|
619
|
+
>
|
|
620
|
+
> It sits at ONE point on purpose: every branch below it acts only on `response.ok`, so all error
|
|
621
|
+
> exits inherit the rewrite without any of them being touched. A subclass overriding the middleware
|
|
622
|
+
> must know the counterpart of the rule above — the response body it forwards is no longer
|
|
623
|
+
> byte-identical to the one Better-Auth produced.
|
|
624
|
+
|
|
611
625
|
#### 2b. SecurityHeadersMiddleware
|
|
612
626
|
|
|
613
627
|
Sets the browser security headers on **every** response — `X-Content-Type-Options`,
|
|
@@ -181,6 +181,21 @@ needed, and no data migration.
|
|
|
181
181
|
There is nothing to do proactively. Affected users are not locked out: the password that works is
|
|
182
182
|
the one from *before* the failed reset.
|
|
183
183
|
|
|
184
|
+
### On an IAM-only deployment, "both stores" means one store
|
|
185
|
+
|
|
186
|
+
The mirror into the legacy bcrypt column runs only where a legacy surface exists — gated on the same
|
|
187
|
+
`auth.legacyEndpoints` resolver the endpoints themselves use. That is deliberate: on an IAM-only
|
|
188
|
+
deployment nothing ever reads that column.
|
|
189
|
+
|
|
190
|
+
The visible effect surprises people: after an IAM reset, `users.password` still holds the OLD hash.
|
|
191
|
+
It is inert — sign-in goes through IAM — but a database inspection shows two different passwords on
|
|
192
|
+
one account and looks like the bug this release fixes.
|
|
193
|
+
|
|
194
|
+
**The part that is not inert:** if you later switch legacy endpoints back on (§1), every account that
|
|
195
|
+
reset while they were off becomes reachable with its **pre-reset** password — including any account
|
|
196
|
+
reset *because* the old password leaked. Before enabling `auth.legacyEndpoints` on a deployment that
|
|
197
|
+
has been IAM-only, force a password change for affected users or clear the stale `password` field.
|
|
198
|
+
|
|
184
199
|
---
|
|
185
200
|
|
|
186
201
|
## 3. Bugfix: native IAM reset routes normalize the password
|
|
@@ -372,7 +387,9 @@ The reference implementation is `src/server/modules/user/user.service.ts`; copy
|
|
|
372
387
|
Two things to check:
|
|
373
388
|
|
|
374
389
|
1. **The return type widened to `null | TUser`.** If you override
|
|
375
|
-
`setPasswordResetTokenForEmail()` or call it directly, handle `null`.
|
|
390
|
+
`setPasswordResetTokenForEmail()` or call it directly, handle `null`. **If your endpoint returned
|
|
391
|
+
`!!user`, it now answers `false` for an unknown address and `true` for a known one** — the same
|
|
392
|
+
oracle, one layer up, reintroduced by this very fix. Return a constant instead.
|
|
376
393
|
2. **Your reset form can no longer say "we do not know this address".** That is the only UX cost —
|
|
377
394
|
the endpoint carries no password, so the "wrong password vs unknown address" distinction is not
|
|
378
395
|
affected (that lives at sign-in, governed by the separate `auth.preventUserEnumeration`).
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Migration Guide: 11.38.x → 11.39.x
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Change | Effort |
|
|
6
|
+
|----------|--------|--------|
|
|
7
|
+
| **Security (Critical)** | A NoSQL operator sent where a token string belongs could take over an account | None here — **but audit your own controllers** (§1) |
|
|
8
|
+
| **Security** | The legacy password-reset token now expires (default 60 min); it never did | Low — unredeemed old links stop working (§2) |
|
|
9
|
+
| **Bugfix (critical path)** | The reset mail can finally contain a token, and a validated link | **Required** if you send that mail yourself (§3) |
|
|
10
|
+
| **Changed** | `redirectTo` from the caller outranks the framework default | None — unless you relied on the default (§4) |
|
|
11
|
+
| **Changed** | Both reset flows resolve the app URL identically | None — the IAM flow gains behaviours it lacked (§4) |
|
|
12
|
+
| **Changed** | IAM error messages carry nest-server's `#LTNS_XXXX:` code | None — unless you match on the English text (§5) |
|
|
13
|
+
| **Bugfix** | The production SMTP default could not send mail at all | **Required** — the fix lives in *your* `config.env.ts` (§6) |
|
|
14
|
+
| **Security** | SMTP now requires STARTTLS instead of accepting a downgrade | Low — one env var if your relay cannot do TLS (§6) |
|
|
15
|
+
| **Changed** | The verification link carries `&email=` so a resend button can render | None (§7) |
|
|
16
|
+
| **Bugfix** | A `Set-Cookie` clear on an IAM failure path was being dropped | None (§8) |
|
|
17
|
+
| **New** | Partial index on `passwordResetToken` | None — created at boot (§9) |
|
|
18
|
+
|
|
19
|
+
Nothing here changes the API surface of `CoreModule.forRoot()`.
|
|
20
|
+
|
|
21
|
+
**This is a MINOR, and it contains breaking changes.** In this package the MAJOR digit states which
|
|
22
|
+
NestJS major is targeted and moves only when NestJS does, so a breaking change ships as a minor. Two
|
|
23
|
+
things can stop working for an existing deployment: unredeemed password-reset links (§2) and SMTP
|
|
24
|
+
against a relay that offers no STARTTLS (§6).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Migration
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm update @lenne.tech/nest-server
|
|
32
|
+
pnpm run build
|
|
33
|
+
pnpm test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then do three things, in this order of consequence:
|
|
37
|
+
|
|
38
|
+
1. **§6 — edit your own `config.env.ts`.** The SMTP fix does not arrive with the package. Without
|
|
39
|
+
this edit a production deployment on the default port sends **no mail at all**, silently.
|
|
40
|
+
2. **§3 — switch your `sendPasswordResetMail()` to the two new methods**, if you send that mail
|
|
41
|
+
yourself. Anything still reading `user.passwordResetToken` off a `process()` result is mailing
|
|
42
|
+
the word `undefined` today.
|
|
43
|
+
3. **§1 — grep your controllers** for `@Body('x') x: string` / `@Query('x') x: string` whose value
|
|
44
|
+
reaches a database query.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 1. Security: an operator where a string belongs
|
|
49
|
+
|
|
50
|
+
`POST /users/password/reset` accepted a JSON **object** in place of the token string:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
{ "token": { "$ne": null }, "password": "…" }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`findOne({ passwordResetToken: { $ne: null } })` then selected the first user holding *any* live
|
|
57
|
+
reset token and set that account's password. The attacker never saw the mail. Confirmed against the
|
|
58
|
+
live route, not inferred: the victim's token was consumed and their password hash changed.
|
|
59
|
+
|
|
60
|
+
**Why nothing caught it.** A parameter declared `@Body('token') token: string` has
|
|
61
|
+
`metatype === String`, and `MapAndValidatePipe` returns basic-type values verbatim — the declared
|
|
62
|
+
type is erased at runtime, so nothing validates it. Express parses query strings with `qs` in
|
|
63
|
+
extended mode, so `?token[$ne]=` reaches a `@Query('token') token: string` identically.
|
|
64
|
+
|
|
65
|
+
This is **pre-existing**, not introduced by 11.38.0. The framework's five affected sinks are now
|
|
66
|
+
guarded.
|
|
67
|
+
|
|
68
|
+
### What you must do
|
|
69
|
+
|
|
70
|
+
**The structural answer is not the guard — it is the DTO.** A parameter typed as a class is
|
|
71
|
+
validated by the pipe; a basic-type parameter is not:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// Exposed: metatype is String, the pipe returns whatever arrived
|
|
75
|
+
async resetPassword(@Body('token') token: string) { … }
|
|
76
|
+
|
|
77
|
+
// Safe: metatype is a class, the pipe validates, an object is rejected before your code runs
|
|
78
|
+
async resetPassword(@Body() input: ResetPasswordInput) { … } // token: @IsString()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For an existing signature you do not want to change, `isQueryableString` is exported:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { isQueryableString } from '@lenne.tech/nest-server';
|
|
85
|
+
|
|
86
|
+
if (!isQueryableString(token)) {
|
|
87
|
+
throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Guard in the **service**, not the controller, if anything other than HTTP reaches it — an MCP tool
|
|
92
|
+
or a cron job calling the service directly bypasses a controller-level check.
|
|
93
|
+
|
|
94
|
+
**Deliberately NOT done:** `mongoose.set('sanitizeFilter', true)` globally. It wraps every
|
|
95
|
+
object-valued filter in `$eq`, which breaks the framework's own operator-bearing queries unless each
|
|
96
|
+
is wrapped in `mongoose.trusted()`. That is a fleet-wide audit, not a fix.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 2. Security: the legacy password-reset token now expires
|
|
101
|
+
|
|
102
|
+
It never did. `resetPassword()` looked the token up by value and compared no time at all — while the
|
|
103
|
+
exception it threw read `Invalid or expired password reset token`, claiming a check that did not
|
|
104
|
+
exist.
|
|
105
|
+
|
|
106
|
+
A reset link takes over an **existing** account, which makes it the most powerful token this
|
|
107
|
+
framework mints. Unbounded, it means a mail in an archive, a forwarded message or a restored backup
|
|
108
|
+
opens that account years later. The IAM half already expired after an hour; only the legacy half had
|
|
109
|
+
nothing.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
auth: {
|
|
113
|
+
passwordReset: { tokenExpiresInMinutes: 60 }, // the default; 0 disables expiry entirely
|
|
114
|
+
},
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Value | Meaning |
|
|
118
|
+
|-------|---------|
|
|
119
|
+
| unset | 60 minutes |
|
|
120
|
+
| a positive number | that many minutes |
|
|
121
|
+
| `0` | no expiry — restores the previous behaviour |
|
|
122
|
+
| negative, `NaN`, empty, non-numeric | **60 minutes**, not "unbounded" |
|
|
123
|
+
|
|
124
|
+
That last row is deliberate. Switching off the expiry of an account-takeover credential is a decision
|
|
125
|
+
somebody has to state; a typo must never be the thing that states it. (`Number('')` is `0`, so an
|
|
126
|
+
empty variable would otherwise read as a deliberate opt-out.)
|
|
127
|
+
|
|
128
|
+
An expired token is answered exactly like an unknown one — **404**, same message — and deleted on
|
|
129
|
+
sight. Distinguishing them would confirm to somebody holding a stale token that it was once real.
|
|
130
|
+
|
|
131
|
+
### Are you affected?
|
|
132
|
+
|
|
133
|
+
| Situation | Effect |
|
|
134
|
+
|-----------|--------|
|
|
135
|
+
| A user holds an unredeemed reset mail from before the upgrade | It stops working. They request a new one — which, for most projects, is the first link that actually works (§3) |
|
|
136
|
+
| Your tests write a `passwordResetToken` directly into the database | **Set `passwordResetTokenExpiresAt` too**, or they now get 404 |
|
|
137
|
+
| You need the old behaviour | `tokenExpiresInMinutes: 0` |
|
|
138
|
+
|
|
139
|
+
`CoreUserModel` gains `passwordResetTokenExpiresAt`, `@Restricted(S_NO_ONE)` like the token itself.
|
|
140
|
+
|
|
141
|
+
### The mail now states the deadline
|
|
142
|
+
|
|
143
|
+
An expiry the recipient is not told about is no better than a broken link — both arrive, look right,
|
|
144
|
+
and fail on click. All three shipped templates state it, from a new optional `templateData` field:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
templateData: { link, linkExpiresInMinutes: 60, name } // omit it, or pass 0, and no sentence renders
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Take the value from the flow you are in: `auth.passwordReset.tokenExpiresInMinutes` for legacy,
|
|
151
|
+
Better-Auth's `resetPasswordTokenExpiresIn` (seconds) for IAM. They expire independently, and
|
|
152
|
+
announcing the other one's deadline is worse than announcing none.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 3. Bugfix: the reset mail can finally carry a token and a working link
|
|
157
|
+
|
|
158
|
+
Two production incidents, one after the other, both reaching real recipients.
|
|
159
|
+
|
|
160
|
+
**The token was never in the link.** `setPasswordResetTokenForEmail()` returns the user through
|
|
161
|
+
`process()`, and the security interceptor strips `passwordResetToken` there — correctly: a reset
|
|
162
|
+
token in a response body is one in a log, a proxy cache and a browser history. So the method named
|
|
163
|
+
after setting the token could not hand it over, and callers mailed `undefined`.
|
|
164
|
+
|
|
165
|
+
**The base was never validated.** `email.passwordResetLink` had no default and callers concatenated
|
|
166
|
+
it anyway, so a project that never set it mailed `undefined/<token>`.
|
|
167
|
+
|
|
168
|
+
Every test stayed green both times, because each checked a piece: the config spec asserted the base,
|
|
169
|
+
the story test read the token from the *database*, the enumeration spec asserted status and timing.
|
|
170
|
+
Nothing asserted the one value the recipient actually receives.
|
|
171
|
+
|
|
172
|
+
### What you must do
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
|
|
176
|
+
// NOT setPasswordResetTokenForEmail — that one cannot return the token, by design.
|
|
177
|
+
const created = await this.createPasswordResetToken(email, serviceOptions);
|
|
178
|
+
if (!created) {
|
|
179
|
+
return null; // unknown address — answer exactly as for a known one
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const link = this.buildPasswordResetLink(created.token);
|
|
183
|
+
if (!link) {
|
|
184
|
+
// Sending a mail whose link cannot work is worse than sending none.
|
|
185
|
+
this.userServiceLogger.error('Password reset mail not sent: set `email.passwordResetLink` or `appUrl`.');
|
|
186
|
+
return created.user;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// NOT awaited: awaiting it makes the known path visibly slower whatever the status code says.
|
|
190
|
+
void this.emailService.sendMail(created.user.email, 'Password reset', {
|
|
191
|
+
htmlTemplate: 'password-reset',
|
|
192
|
+
templateData: { link, linkExpiresInMinutes: this.passwordResetTokenExpiryMinutes(), name: created.user.username },
|
|
193
|
+
}).catch((error: unknown) => this.userServiceLogger.error(`Failed to send: ${String(error)}`));
|
|
194
|
+
|
|
195
|
+
return created.user;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The reference implementation is `src/server/modules/user/user.service.ts`; copy that shape.
|
|
200
|
+
|
|
201
|
+
### Two link conventions, and which applies depends on whether you set the option at all
|
|
202
|
+
|
|
203
|
+
| `email.passwordResetLink` | Resulting link |
|
|
204
|
+
|---------------------------|----------------|
|
|
205
|
+
| not set | `<appUrl>/auth/reset-password?token=<token>` |
|
|
206
|
+
| set, containing `{token}` | your value, placeholder substituted |
|
|
207
|
+
| set, **without** `{token}` | your value + `/<token>` as a **path segment** |
|
|
208
|
+
|
|
209
|
+
Writing the default out by hand therefore gives a **different** link than leaving the line out. That
|
|
210
|
+
looks like a trap and is kept deliberately: a project that configures such a value has a page built
|
|
211
|
+
for a path segment, and moving it silently would break the very flow this release repairs. The unset
|
|
212
|
+
default instead matches the page `nuxt-base-starter` ships, which reads `route.query.token`.
|
|
213
|
+
|
|
214
|
+
Because the rule is not guessable, **the framework warns once at boot** when the option is set
|
|
215
|
+
without `{token}`. The warning names a precondition: `{token}` is substituted by
|
|
216
|
+
`buildPasswordResetLink()` and nothing else, so a caller still concatenating by hand must switch
|
|
217
|
+
first or it mails the placeholder verbatim.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 4. Changed: `redirectTo` wins, and both flows resolve the app URL identically
|
|
222
|
+
|
|
223
|
+
**Your own `redirectTo` now outranks the framework default.** If the client passed one to
|
|
224
|
+
`requestPasswordReset` — `nuxt-base-starter` does on every request — the mail links there.
|
|
225
|
+
Resolution order: explicit config → the caller's `redirectTo` → `<appUrl>/auth/reset-password` →
|
|
226
|
+
Better-Auth's own link. `passwordResetLink: false` still wins over a `redirectTo`.
|
|
227
|
+
|
|
228
|
+
This matters for a project that renamed its reset route: before, the mail carried the framework's
|
|
229
|
+
guess and the recipient landed on a route that does not exist. It also restores exact equivalence
|
|
230
|
+
with Better-Auth's redirect hop, which sends the user to `redirectTo?token=`.
|
|
231
|
+
|
|
232
|
+
**On safety:** `redirectTo` arrives in the body of an unauthenticated endpoint, so putting it into a
|
|
233
|
+
mail would be a token-exfiltration vector — were it not validated first. Better-Auth runs
|
|
234
|
+
`originCheck` on it at request time, so a value outside your `trustedOrigins` is rejected before any
|
|
235
|
+
mail is built. That makes `trustedOrigins` load-bearing for this path as well as for CORS; keep
|
|
236
|
+
`cors.allowedOrigins` as narrow as your frontends need.
|
|
237
|
+
|
|
238
|
+
**Both flows now resolve the app URL through one function.** The IAM flow previously read `appUrl`
|
|
239
|
+
straight off the configuration while the legacy flow had been moved to the shared resolver — two
|
|
240
|
+
hand-maintained copies of one decision, and they drifted. The IAM flow gains three behaviours it
|
|
241
|
+
lacked: the localhost default that `local`/`ci`/`e2e` depend on (none of them set `appUrl`),
|
|
242
|
+
derivation from a host-split `baseUrl` such as `https://api.crm.localhost`, and the
|
|
243
|
+
`cors.deriveAppUrl` opt-out that keeps a reset token out of an untrusted apex domain.
|
|
244
|
+
|
|
245
|
+
In practice this changes the IAM link only where `appUrl` is unset — local and CI. Production, where
|
|
246
|
+
it is set, is unaffected.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 5. Changed: IAM error messages carry nest-server's error code
|
|
251
|
+
|
|
252
|
+
Better-Auth answers with its own vocabulary — `{ code: 'INVALID_TOKEN', message: 'Invalid token' }` —
|
|
253
|
+
while the frontends in this stack translate by parsing nest-server's marker. The parser found no
|
|
254
|
+
code and passed the raw string through, so **every** IAM error reached end users as English
|
|
255
|
+
developer text. It was felt hardest where it could least be afforded: an expired reset link answers
|
|
256
|
+
`INVALID_TOKEN`, so the person who already cannot sign in was the one reading it.
|
|
257
|
+
|
|
258
|
+
```jsonc
|
|
259
|
+
// before
|
|
260
|
+
{ "code": "INVALID_TOKEN", "message": "Invalid token" }
|
|
261
|
+
// after
|
|
262
|
+
{ "code": "INVALID_TOKEN", "message": "#LTNS_0027: Link is invalid or expired" }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**The `code` field is unchanged**, so anything branching on it keeps working.
|
|
266
|
+
|
|
267
|
+
New codes: `LTNS_0027 LINK_INVALID_OR_EXPIRED`, `LTNS_0028 PASSWORD_TOO_LONG`,
|
|
268
|
+
`LTNS_0029 PASSWORD_TOO_SHORT`.
|
|
269
|
+
|
|
270
|
+
Three deliberate absences, each for a reason worth knowing:
|
|
271
|
+
|
|
272
|
+
- **`INVALID_TOKEN` and `TOKEN_EXPIRED` map to the same message.** Better-Auth throws
|
|
273
|
+
`TOKEN_EXPIRED` in exactly one place — an expired verification *link*, one line above the
|
|
274
|
+
`INVALID_TOKEN` it throws for a broken one. Same user action, so the same instruction. The
|
|
275
|
+
session-oriented "sign in again" would be a dead end there: at that moment the user is usually not
|
|
276
|
+
signed in at all.
|
|
277
|
+
- **`USER_NOT_FOUND` is left untranslated.** It is an account-enumeration signal. Better-Auth already
|
|
278
|
+
exposes it as a `code`, but translating it would make the oracle friendlier and more legible — the
|
|
279
|
+
opposite direction from the rest of this release.
|
|
280
|
+
- **`SESSION_EXPIRED` is left untranslated.** It means "re-authenticate for this action", which is
|
|
281
|
+
not what nest-server's `TOKEN_EXPIRED` tells the user to do.
|
|
282
|
+
|
|
283
|
+
Reachable programmatically via `mapBetterAuthErrorCode(code)`.
|
|
284
|
+
|
|
285
|
+
### Two boundaries
|
|
286
|
+
|
|
287
|
+
- **A redirect is not rewritten.** Better-Auth reports some failures by redirecting to your
|
|
288
|
+
`callbackURL` with `?error=<CODE>` rather than answering with a body — nothing to translate, and
|
|
289
|
+
rewriting that query string would invent a second contract over its documented one. Read `?error=`
|
|
290
|
+
in the frontend. Narrower than it sounds: with `betterAuth.emailVerification.callbackURL` set —
|
|
291
|
+
which the starter does — the mail links straight at the frontend and the redirect route is never
|
|
292
|
+
walked.
|
|
293
|
+
- **`PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` are rarely reachable through the lt frontend**, which
|
|
294
|
+
hashes with `ltSha256` before sending, so every password arrives as 64 hex characters. They remain
|
|
295
|
+
reachable for a client that does not hash, and for a project configuring `minPasswordLength` above
|
|
296
|
+
64.
|
|
297
|
+
|
|
298
|
+
### Are you affected?
|
|
299
|
+
|
|
300
|
+
| Situation | Effect |
|
|
301
|
+
|-----------|--------|
|
|
302
|
+
| You use `@lenne.tech/nuxt-extensions` | Errors become translatable; no change needed |
|
|
303
|
+
| You branch on `error.code` | Unaffected |
|
|
304
|
+
| You match on the exact English `message` string | **Update it** — prefer `code`, which is stable |
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## 6. Bugfix: the production SMTP default could not send mail
|
|
309
|
+
|
|
310
|
+
The `production` profile paired the default port **587** with `secure` resolving to **true**. Those
|
|
311
|
+
two cannot go together: `secure` means implicit TLS, which only port **465** speaks, while 587 opens
|
|
312
|
+
in plaintext and upgrades through STARTTLS. Nodemailer sent a TLS ClientHello to a server answering
|
|
313
|
+
with an SMTP greeting, and OpenSSL reported `wrong version number`.
|
|
314
|
+
|
|
315
|
+
A deployment configuring only `SMTP_HOST` and credentials could therefore send **no mail at all** —
|
|
316
|
+
invisibly, because authentication mail is deliberately not awaited, so the failure never reached a
|
|
317
|
+
response. The API answered 200 while every password-reset message died in transport.
|
|
318
|
+
|
|
319
|
+
### This does NOT arrive with `pnpm update`
|
|
320
|
+
|
|
321
|
+
The fix landed in the framework's own `src/config.env.ts`; your project has its own copy, seeded from
|
|
322
|
+
the starter at init and never re-synced. Upgrading gives you a boot warning — the repair is an edit
|
|
323
|
+
you make:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { resolveSmtpSecure } from '@lenne.tech/nest-server';
|
|
327
|
+
|
|
328
|
+
const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
|
|
329
|
+
// …
|
|
330
|
+
smtp: {
|
|
331
|
+
host: process.env.SMTP_HOST,
|
|
332
|
+
port: smtpPort,
|
|
333
|
+
- secure: process.env.SMTP_SECURE !== 'false',
|
|
334
|
+
+ secure: resolveSmtpSecure(process.env.SMTP_SECURE, smtpPort),
|
|
335
|
+
+ requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
|
|
336
|
+
},
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
| `SMTP_SECURE` | Result |
|
|
340
|
+
|---------------|--------|
|
|
341
|
+
| `true` / `false` | honoured, even where it cannot work — and warned about |
|
|
342
|
+
| unset, or anything else (`1`, `yes`, a typo) | derived: `true` on port 465, `false` otherwise |
|
|
343
|
+
|
|
344
|
+
Only the two canonical values override, because `secure` is not an independent setting but a
|
|
345
|
+
consequence of the port. Both obvious string rules have a silent wrong side — `!== 'false'` makes an
|
|
346
|
+
unknown value `true` on 587 (this outage), `=== 'true'` makes `1` resolve to `false` on 465 (the same
|
|
347
|
+
outage mirrored). Deferring to the port is the only rule under which no input produces a pair that
|
|
348
|
+
cannot connect.
|
|
349
|
+
|
|
350
|
+
### `requireTLS` is the second half, and it is new
|
|
351
|
+
|
|
352
|
+
`secure: false` alone means **opportunistic** STARTTLS: nodemailer upgrades only when the server
|
|
353
|
+
advertises it. An on-path attacker who strips that capability line gets a plaintext session carrying
|
|
354
|
+
your SMTP credentials and a working password-reset link. Before the repair nothing was sent at all,
|
|
355
|
+
so this exposure is created by the fix — which is why the fix has to include the floor.
|
|
356
|
+
|
|
357
|
+
Set `SMTP_REQUIRE_TLS=false` only for an internal relay that genuinely offers no STARTTLS.
|
|
358
|
+
`EmailService` warns once when the resolved pair cannot connect — reported, never overruled.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## 7. Changed: the verification link carries the address
|
|
363
|
+
|
|
364
|
+
`{callbackURL}?token=<token>&email=<address>`. The verification page needs it to offer "send a new
|
|
365
|
+
email" once the token has expired, and it cannot recover the address itself: that value lives inside
|
|
366
|
+
the token's JWT payload, and reading it there would mean rendering data from an unverified
|
|
367
|
+
signature. Without it, a user with an expired link is told correctly what happened and given no way
|
|
368
|
+
to fix it.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 8. Bugfix: a `Set-Cookie` clear was being dropped
|
|
373
|
+
|
|
374
|
+
The header rebuild in the new error wrapper used `Headers.forEach` + `set()`. `forEach` yields each
|
|
375
|
+
`set-cookie` entry separately and `set()` overwrites, so all but the last were discarded. Better-Auth
|
|
376
|
+
**clears** session and 2FA cookies on several failure paths — precisely the responses being rewritten
|
|
377
|
+
— so the clear was lost and a stale credential stayed in the browser.
|
|
378
|
+
|
|
379
|
+
No action required. Noted because a failed 2FA attempt against an 11.39.0 pre-release could have left
|
|
380
|
+
a session cookie behind.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 9. New: partial index on `passwordResetToken`
|
|
385
|
+
|
|
386
|
+
`findOne({ passwordResetToken: token })` runs on an unauthenticated, unrated endpoint and had no
|
|
387
|
+
index — a full collection scan of `users` per call. The index is partial
|
|
388
|
+
(`partialFilterExpression: { passwordResetToken: { $type: 'string' } }`) and the reset path now
|
|
389
|
+
`$unset`s the field rather than writing `null`, so it stays small.
|
|
390
|
+
|
|
391
|
+
**Never give `passwordResetTokenExpiresAt` a TTL index.** It is a `Date`, and a TTL index there would
|
|
392
|
+
delete the **user document**, not the token.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## 10. Operations: two boot warnings you may now see
|
|
397
|
+
|
|
398
|
+
**`jwks` under a throwaway or fallback secret.** The JWT plugin persists a `jwks` document encrypted
|
|
399
|
+
with whatever secret is active. Booting once without a permanent `betterAuth.secret` — or on the
|
|
400
|
+
`jwt.secret` fallback — leaves a row that a later, different secret cannot decrypt. Nothing fails at
|
|
401
|
+
the time; `/iam/token` starts answering `Failed to decrypt private key` whenever somebody next asks
|
|
402
|
+
for a JWT, which on a low-traffic deployment can be months later. Observed in production with five
|
|
403
|
+
weeks between cause and symptom.
|
|
404
|
+
|
|
405
|
+
Remedy: reuse the same value, or drop the `jwks` collection after setting a permanent secret.
|
|
406
|
+
Better-Auth creates a new key; previously issued JWTs were already unusable.
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## 11. Vendor mode: one new file
|
|
411
|
+
|
|
412
|
+
| New file | Existing core files that now import it |
|
|
413
|
+
|---|---|
|
|
414
|
+
| `better-auth/core-better-auth-error-codes.helper.ts` | `better-auth/core-better-auth-api.middleware.ts`, `better-auth/index.ts` |
|
|
415
|
+
|
|
416
|
+
Taking the middleware without the helper leaves an unresolvable import.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## 12. Dependencies: two security overrides raised
|
|
421
|
+
|
|
422
|
+
`fast-uri` and `qs` were already overridden, and both entries had **aged past their own patch line**.
|
|
423
|
+
A stale override is not inert — it pins matching paths *below* the current fix, and adding a second,
|
|
424
|
+
higher entry does not help because the narrower key claims its paths first and the tree ends up
|
|
425
|
+
carrying both versions.
|
|
426
|
+
|
|
427
|
+
Raised in place: `'fast-uri@<3.1.6': '3.1.6'` and `'qs@<6.16.0': '6.16.0'`. `pnpm audit` is clean.
|
|
428
|
+
|
|
429
|
+
If you maintain your own overrides, the tell is: **`pnpm audit` reporting a package you already
|
|
430
|
+
override is never "transitive, pre-existing, not our problem"** — it means the entry is the cause.
|
|
431
|
+
See `.claude/rules/package-management.md` for both aging modes.
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Troubleshooting
|
|
436
|
+
|
|
437
|
+
| Symptom | Cause | Fix |
|
|
438
|
+
|---------|-------|-----|
|
|
439
|
+
| The reset mail link ends in `/undefined` | §3 — the token was read off a `process()` result, where the interceptor strips it | `createPasswordResetToken()`, read `created.token` |
|
|
440
|
+
| The reset mail link STARTS with `undefined/` | §3 — `email.passwordResetLink` unset and the caller concatenated anyway | `buildPasswordResetLink()`, which returns `null` instead of guessing |
|
|
441
|
+
| The reset page reports an invalid token, but the URL looks fine | §3 — the link carries a path segment and the page reads `?token=`, or the reverse | Configure `email.passwordResetLink` with `{token}` where your page expects it |
|
|
442
|
+
| A reset link that worked yesterday now 404s | §2 — tokens expire after 60 minutes | Request a new one, or `tokenExpiresInMinutes: 0` |
|
|
443
|
+
| `POST /users/password/reset` answers 404 for a token that exists | §1 — the value was not a plain string, or §2 — it expired | Both answer identically on purpose |
|
|
444
|
+
| Production sends no mail and the log shows `wrong version number` | §6 — port 587 with `secure: true` | Apply the `config.env.ts` edit |
|
|
445
|
+
| Production sends no mail and the log mentions STARTTLS | §6 — `requireTLS` and a relay that offers none | `SMTP_REQUIRE_TLS=false`, after confirming the relay really cannot |
|
|
446
|
+
| `/iam/token` fails with `Failed to decrypt private key` | §10 — a `jwks` key encrypted under a different secret | Drop the `jwks` collection after setting a permanent secret |
|
|
447
|
+
| Your tests get 404 on a reset token they wrote directly | §2 — the row has no `passwordResetTokenExpiresAt` and counts as expired | Set it in the fixture |
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Module Documentation
|
|
452
|
+
|
|
453
|
+
- [BetterAuth README](../src/core/modules/better-auth/README.md)
|
|
454
|
+
- [BetterAuth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
|
|
455
|
+
- [Request Lifecycle](../docs/REQUEST-LIFECYCLE.md)
|
|
456
|
+
- [Configurable Features](../.claude/rules/configurable-features.md)
|