@lenne.tech/nest-server 11.37.0 → 11.38.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 +29 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/testing.md +26 -4
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +1 -1
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
- package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
- package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
- package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
- package/dist/core/modules/auth/core-auth.controller.js +2 -5
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +2 -0
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -5
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js +33 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
- 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 +2 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
- 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-password-reset.registry.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -1
- package/dist/core/modules/user/core-user.service.js +29 -12
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.resolver.js +2 -1
- package/dist/server/modules/user/user.resolver.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +8 -1
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +35 -1
- package/migration-guides/11.37.x-to-11.38.x.md +511 -0
- package/package.json +1 -1
- package/src/config.env.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +225 -9
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
- package/src/core/modules/auth/README.md +104 -0
- package/src/core/modules/auth/core-auth.controller.ts +4 -11
- package/src/core/modules/auth/core-auth.module.ts +5 -0
- package/src/core/modules/auth/core-auth.resolver.ts +4 -11
- package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
- package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
- package/src/core/modules/better-auth/README.md +32 -59
- package/src/core/modules/better-auth/better-auth.config.ts +106 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
- package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- package/src/core/modules/user/core-user.service.ts +123 -18
- package/src/core/modules/user/inputs/core-user.input.ts +16 -1
- package/src/core.module.ts +8 -0
- package/src/index.ts +3 -0
- package/src/server/modules/user/user.controller.ts +7 -1
- package/src/server/modules/user/user.resolver.ts +7 -1
- package/src/server/modules/user/user.service.ts +35 -9
|
@@ -78,7 +78,7 @@ JWT-based authentication for existing projects:
|
|
|
78
78
|
| **Refresh Tokens** | Automatic token renewal |
|
|
79
79
|
| **Sign In / Sign Up / Logout** | GraphQL mutations + REST endpoints |
|
|
80
80
|
| **Rate Limiting** | Configurable per-endpoint rate limits (`auth.rateLimit`). Same `RateLimitStore` selection, async signatures and Redis-outage degradation as the BetterAuth row above (namespace `legacy-auth`) |
|
|
81
|
-
| **Legacy Endpoint Controls** |
|
|
81
|
+
| **Legacy Endpoint Controls** | Legacy endpoints are OFF unless `auth.legacyEndpoints.enabled: true` (default flipped in 11.38.0 — it was an opt-out, it is now an opt-in). `CoreLegacyAuthDeprecationInitializer` reports the state and the IAM migration progress at boot |
|
|
82
82
|
| **Migration Tracking** | `betterAuthMigrationStatus` query for monitoring |
|
|
83
83
|
|
|
84
84
|
#### Role System
|
|
@@ -592,6 +592,40 @@ If authentication succeeds, `req.user` is set with the authenticated user (inclu
|
|
|
592
592
|
> raw-forwarded routes. A forged `POST /iam/update-user {"roles":["admin"]}` is rejected with
|
|
593
593
|
> `FIELD_NOT_ALLOWED` (HTTP 400) at the input-parse stage, before any persistence.
|
|
594
594
|
|
|
595
|
+
> **"Raw" has one exception since 11.38.0: the password-setting reset routes.**
|
|
596
|
+
> `CoreBetterAuthApiMiddleware.normalizeResetPassword()` rewrites the password field of
|
|
597
|
+
> `/reset-password`, `/email-otp/reset-password` and `/phone-number/reset-password` to its
|
|
598
|
+
> normalized (sha256) form **before** building the Web Request, then runs the Better-Auth handler
|
|
599
|
+
> inside an `AsyncLocalStorage` context carrying that value.
|
|
600
|
+
>
|
|
601
|
+
> Both halves are load-bearing. Without the rewrite, a client posting a plaintext password would
|
|
602
|
+
> have `scrypt(plaintext)` stored while every sign-in — which *is* normalized, in
|
|
603
|
+
> `CoreBetterAuthController` — checks `scrypt(sha256(...))`: the account is locked out with the
|
|
604
|
+
> password its owner just chose. Without the context, `emailAndPassword.onPasswordReset` knows
|
|
605
|
+
> *which* user was reset but not to *what*, and cannot mirror the new password into the legacy
|
|
606
|
+
> bcrypt store — leaving the old password valid on the legacy path after a reset.
|
|
607
|
+
>
|
|
608
|
+
> A project subclassing the middleware must know this: the body it forwards is no longer
|
|
609
|
+
> byte-identical to the body it received. `normalizeResetPassword()` is `protected`.
|
|
610
|
+
|
|
611
|
+
#### 2b. SecurityHeadersMiddleware
|
|
612
|
+
|
|
613
|
+
Sets the browser security headers on **every** response — `X-Content-Type-Options`,
|
|
614
|
+
`X-Frame-Options`, `Referrer-Policy`, `Strict-Transport-Security`, and the removal of
|
|
615
|
+
`X-Powered-By`. On by default; `security.headers: false` disables it, individual fields switch off
|
|
616
|
+
individually. No CSP is sent unless one is configured.
|
|
617
|
+
|
|
618
|
+
Middleware rather than an interceptor, and the distinction is the whole point: an interceptor never
|
|
619
|
+
runs for a request a guard turns away, and a `401` is still a response a browser renders. Those are
|
|
620
|
+
also the responses an attacker generates most of. `tests/security-headers.e2e-spec.ts` asserts the
|
|
621
|
+
headers on an actual guard rejection rather than trusting the ordering.
|
|
622
|
+
|
|
623
|
+
HSTS is decided by the request protocol — `x-forwarded-proto` first, connection protocol as
|
|
624
|
+
fallback — and never by configuration. A browser remembers it, so one sent over `http://localhost`
|
|
625
|
+
makes every project on that host unreachable over http for up to a year, with no server-side undo.
|
|
626
|
+
Behind a TLS-terminating proxy the inward connection is plain http, which makes `trustProxy`
|
|
627
|
+
load-bearing for this header too.
|
|
628
|
+
|
|
595
629
|
#### 3. graphqlUploadExpress
|
|
596
630
|
|
|
597
631
|
Only for GraphQL routes. Handles multipart file upload requests according to the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# Migration Guide: 11.37.x → 11.38.x
|
|
2
|
+
|
|
3
|
+
> **This MINOR contains breaking changes.** In this repository the MAJOR digit states which NestJS
|
|
4
|
+
> major the package targets and moves only when NestJS does, so a breaking change ships as a MINOR.
|
|
5
|
+
> Read the version number as "NestJS 11, breaking step 38", not as a semver promise.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
| Category | Change | Effort |
|
|
10
|
+
|----------|--------|--------|
|
|
11
|
+
| **Breaking** | `auth.legacyEndpoints.enabled` now defaults to **`false`** | Low — one config line, only for projects still using Legacy Auth |
|
|
12
|
+
| **Bugfix (security)** | A password reset now reaches **both** credential stores | None — but read *Existing users* below |
|
|
13
|
+
| **Bugfix** | Native IAM reset routes normalize the password like sign-in does | None |
|
|
14
|
+
| **New** | Boot warning naming the IAM migration status while Legacy Auth is open | None |
|
|
15
|
+
| **Breaking (safe direction)** | Security headers are sent by default | None — unless a proxy already sets conflicting ones |
|
|
16
|
+
| **Breaking (safe direction)** | The password-reset request no longer reveals whether an account exists | Low — one config line if you want the old hint back |
|
|
17
|
+
| **Changed** | The password-reset mail links to the app instead of the API | Low — only if your reset page reads a PATH parameter |
|
|
18
|
+
|
|
19
|
+
Nothing here changes the API surface of `CoreModule.forRoot()`. A project created from
|
|
20
|
+
`nest-server-starter` — i.e. `CoreModule.forRoot(envConfig)`, IAM only — is unaffected by the
|
|
21
|
+
breaking change and only gains the fixes.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick Migration
|
|
26
|
+
|
|
27
|
+
For an IAM-only project — the one-argument `CoreModule.forRoot(envConfig)` the starter uses —
|
|
28
|
+
this release needs no code change at all:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm update @lenne.tech/nest-server
|
|
32
|
+
pnpm run build
|
|
33
|
+
pnpm test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If you call the **three-argument** form, decide one config line first (§1):
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// config.env.ts — only if your users still need Legacy Auth
|
|
40
|
+
auth: {
|
|
41
|
+
legacyEndpoints: { enabled: true },
|
|
42
|
+
},
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then upgrade as above. The boot log tells you when you can remove that line again.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 1. Breaking: Legacy Auth endpoints are off unless asked for
|
|
50
|
+
|
|
51
|
+
### What changed
|
|
52
|
+
|
|
53
|
+
`auth.legacyEndpoints.enabled` defaulted to `true`. A project that registered the legacy auth module
|
|
54
|
+
and never made a decision therefore kept a second, fully functional password-authentication surface
|
|
55
|
+
open indefinitely — `signIn`, `signUp`, `logout`, `refreshToken` over GraphQL and
|
|
56
|
+
`/auth/signin`, `/auth/signup`, `/auth/logout`, `/auth/refresh-token` over REST.
|
|
57
|
+
|
|
58
|
+
Since 11.38.0 the default is `false`. Disabled endpoints answer **HTTP 410 Gone**, as they always
|
|
59
|
+
did when switched off explicitly.
|
|
60
|
+
|
|
61
|
+
### Are you affected?
|
|
62
|
+
|
|
63
|
+
Only if **all** of these are true:
|
|
64
|
+
|
|
65
|
+
1. You call the three-argument form
|
|
66
|
+
`CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(envConfig.jwt), envConfig)`, **and**
|
|
67
|
+
2. you did not set `auth.legacyEndpoints` at all, **and**
|
|
68
|
+
3. a client still uses a legacy endpoint.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Do you register the legacy module?
|
|
72
|
+
grep -n "CoreModule.forRoot(" src/server/server.module.ts
|
|
73
|
+
|
|
74
|
+
# Did you already decide?
|
|
75
|
+
grep -n "legacyEndpoints" src/config.env.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`CoreModule.forRoot(envConfig)` — the one-argument form the starter uses — never registers the
|
|
79
|
+
legacy module, so none of this applies.
|
|
80
|
+
|
|
81
|
+
### Migration
|
|
82
|
+
|
|
83
|
+
If your users still need Legacy Auth, say so explicitly:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// config.env.ts
|
|
87
|
+
auth: {
|
|
88
|
+
legacyEndpoints: { enabled: true },
|
|
89
|
+
},
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If you were already migrating, this is the moment to finish. Ask the server how far it got:
|
|
93
|
+
|
|
94
|
+
```graphql
|
|
95
|
+
query {
|
|
96
|
+
betterAuthMigrationStatus {
|
|
97
|
+
totalUsers
|
|
98
|
+
fullyMigratedUsers
|
|
99
|
+
migrationPercentage
|
|
100
|
+
canDisableLegacyAuth
|
|
101
|
+
pendingUserEmails
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Once `canDisableLegacyAuth` is `true`, leave the new default in place and delete the config line.
|
|
107
|
+
|
|
108
|
+
Since 11.38.0 you no longer have to ask: while any legacy endpoint is open, every boot logs the
|
|
109
|
+
same figure. It is **best-effort** — the read runs detached from the boot so it cannot delay
|
|
110
|
+
readiness, and a failure is swallowed at debug level. So the deprecation warning can appear
|
|
111
|
+
without a percentage line under it; that means the status could not be read, not that there is
|
|
112
|
+
nothing to report.
|
|
113
|
+
|
|
114
|
+
The mirror case is reported too, and it is the one worth watching after this upgrade: when the
|
|
115
|
+
endpoints are closed **while users are still unmigrated**, the boot says so and names the way
|
|
116
|
+
back.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
[CoreLegacyAuthDeprecationInitializer] Legacy Auth is ENABLED (GraphQL + REST). It is deprecated
|
|
120
|
+
in favour of IAM (Better-Auth) and will be removed. Close it with
|
|
121
|
+
auth.legacyEndpoints.enabled: false once your users are migrated.
|
|
122
|
+
[CoreLegacyAuthDeprecationInitializer] IAM migration status: 84/97 users migrated (87%).
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Per-transport flags
|
|
126
|
+
|
|
127
|
+
`graphql` and `rest` still override `enabled` for one transport, with one rule made explicit:
|
|
128
|
+
**an explicit `enabled: false` cannot be reopened by a per-transport `true`.** It is the setting a
|
|
129
|
+
project reached for to close legacy down, and an upgrade must never widen it.
|
|
130
|
+
|
|
131
|
+
| Configuration | Result |
|
|
132
|
+
|---------------|--------|
|
|
133
|
+
| `enabled: false` | off, whatever `graphql` / `rest` say |
|
|
134
|
+
| `graphql: true` (no `enabled`) | GraphQL on, REST off |
|
|
135
|
+
| `enabled: true` | both on |
|
|
136
|
+
| nothing set | **off** (was: on) |
|
|
137
|
+
|
|
138
|
+
Both transports now resolve this through one shared function, `isLegacyEndpointEnabled()`, exported
|
|
139
|
+
from the package. If you override `checkLegacyGraphQLEnabled()` or `checkLegacyRESTEnabled()`, use
|
|
140
|
+
it rather than re-implementing the table.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 2. Security fix: a password reset now reaches both credential stores
|
|
145
|
+
|
|
146
|
+
### What was wrong
|
|
147
|
+
|
|
148
|
+
Legacy Auth stores `bcrypt(sha256(password))`, IAM stores `scrypt(sha256(password))`. Neither hash
|
|
149
|
+
can be derived from the other, so a deployment running both has to write both on every password
|
|
150
|
+
change. Two halves of that were broken, in opposite directions:
|
|
151
|
+
|
|
152
|
+
**Legacy reset → IAM.** `CoreUserService.resetPassword()` and `.update()` skipped the IAM sync
|
|
153
|
+
whenever the incoming password was already a 64-character hex string, on the reasoning that IAM
|
|
154
|
+
"needs the plain password". It does not — `hashPasswordForBetterAuth()` runs its input through
|
|
155
|
+
`normalizePasswordForIam()`, which passes a sha256 value through unchanged. And the lt auth client
|
|
156
|
+
hashes in the browser (`ltSha256`, seven call sites in `@lenne.tech/nuxt-extensions`), so **the
|
|
157
|
+
skipped branch was the only branch a real frontend ever took**. The legacy store got the new
|
|
158
|
+
password, the IAM credential kept the old one, and the endpoint reported success either way.
|
|
159
|
+
|
|
160
|
+
**IAM reset → legacy.** Nothing mirrored a Better-Auth reset back into the legacy store at all.
|
|
161
|
+
|
|
162
|
+
The failure only surfaced at the next sign-in, to somebody who by definition had already lost their
|
|
163
|
+
way in — and a reset performed *because* a password leaked did not actually revoke it.
|
|
164
|
+
|
|
165
|
+
### What changed
|
|
166
|
+
|
|
167
|
+
- Both `resetPassword()` and `update()` pass the submitted password to the IAM sync exactly as
|
|
168
|
+
received, whatever its shape.
|
|
169
|
+
- `emailAndPassword.onPasswordReset` is now wired and mirrors an IAM reset into the legacy bcrypt
|
|
170
|
+
store, for every native reset route (token, email-OTP, phone-number).
|
|
171
|
+
- A sync that does **not** happen is now logged as a warning instead of a success line. A silent
|
|
172
|
+
`false` return is how this stayed invisible.
|
|
173
|
+
|
|
174
|
+
### Existing users
|
|
175
|
+
|
|
176
|
+
The fix is forward-only for accounts that already reset during the broken window: their two stores
|
|
177
|
+
still hold different passwords. **One further reset repairs the account** — `syncPasswordChangeToIam`
|
|
178
|
+
rewrites an existing credential, so both stores converge in a single pass. No repair script is
|
|
179
|
+
needed, and no data migration.
|
|
180
|
+
|
|
181
|
+
There is nothing to do proactively. Affected users are not locked out: the password that works is
|
|
182
|
+
the one from *before* the failed reset.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 3. Bugfix: native IAM reset routes normalize the password
|
|
187
|
+
|
|
188
|
+
`/iam/sign-in/email` and `/iam/sign-up/email` are handled by `CoreBetterAuthController`, which
|
|
189
|
+
normalizes the password (`normalizePasswordForIam`). The native reset routes are forwarded to
|
|
190
|
+
Better-Auth instead, and nothing normalized them.
|
|
191
|
+
|
|
192
|
+
So a client that posted a **plaintext** password to `POST /iam/reset-password` stored
|
|
193
|
+
`scrypt(plaintext)`, while every later sign-in checks `scrypt(sha256(plaintext))` — the account was
|
|
194
|
+
locked out with the password its owner had just chosen, one request after being told the reset
|
|
195
|
+
succeeded. A client that hashes first (every lt frontend) was unaffected, which is why it went
|
|
196
|
+
unnoticed.
|
|
197
|
+
|
|
198
|
+
`CoreBetterAuthApiMiddleware` now normalizes `/reset-password`, `/email-otp/reset-password` and
|
|
199
|
+
`/phone-number/reset-password` before forwarding. No configuration required — with two things
|
|
200
|
+
worth knowing:
|
|
201
|
+
|
|
202
|
+
**Password length is now enforced by nest-server, and only on what the client actually sends.**
|
|
203
|
+
Because the body is normalized to a 64-character sha256 before Better-Auth sees it, Better-Auth's
|
|
204
|
+
own `minPasswordLength` / `maxPasswordLength` can never fire on these routes. nest-server therefore
|
|
205
|
+
checks the submitted value itself (8–128 by default, or your
|
|
206
|
+
`betterAuth.options.emailAndPassword.minPasswordLength` / `maxPasswordLength`) and answers **400**
|
|
207
|
+
on a violation.
|
|
208
|
+
|
|
209
|
+
Two things worth stating exactly, because it is easy to read this as a regression it is not:
|
|
210
|
+
|
|
211
|
+
- **For a client that hashes before sending — every lt frontend does — nothing changed.**
|
|
212
|
+
Better-Auth's length check reads the submitted value, so a 64-character sha256 already defeated
|
|
213
|
+
it on every earlier version. This release does not open that gap; it makes it visible.
|
|
214
|
+
- **For a client that sends plaintext, this is a net gain.** Better-Auth's check used to apply and
|
|
215
|
+
now cannot, so nest-server's replaces it — same enforcement, one layer earlier.
|
|
216
|
+
|
|
217
|
+
Either way, **a hashed password carries no length information, so the policy has to live in your
|
|
218
|
+
form as well.** The starter's `reset-password.vue`, `register.vue` and `setup.vue` all declare
|
|
219
|
+
`v.minLength(8)`; a project using `@lenne.tech/nuxt-extensions` without those forms has to add it.
|
|
220
|
+
|
|
221
|
+
**Normalization needs `CoreBetterAuthUserMapper` resolvable.** Every module variant that registers
|
|
222
|
+
the middleware also provides the mapper, so this is not something you configure. If it is somehow
|
|
223
|
+
absent the middleware now logs an **error** naming the consequence rather than forwarding quietly —
|
|
224
|
+
the previous silence would have stored `scrypt(plaintext)` against a sign-in that checks
|
|
225
|
+
`scrypt(sha256(...))`.
|
|
226
|
+
|
|
227
|
+
### Sessions are not revoked by a reset unless you ask
|
|
228
|
+
|
|
229
|
+
Related, and easy to assume otherwise now that the parity fix has landed: **a reset lands in both
|
|
230
|
+
stores, but that is not the same as securing the account.** Since 11.38.0 the legacy reset also
|
|
231
|
+
clears `refreshTokens`, so legacy sessions end — but the IAM half is opt-in:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
betterAuth: {
|
|
235
|
+
emailAndPassword: {
|
|
236
|
+
// Recommended. A reset is what somebody reaches for after a suspected takeover.
|
|
237
|
+
revokeSessionsOnPasswordReset: true,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Off by default because it is a behaviour change for an existing deployment: after a reset the user
|
|
243
|
+
is signed out everywhere, including on the devices they still hold.
|
|
244
|
+
|
|
245
|
+
### Rate limiting is part of the definition of done here
|
|
246
|
+
|
|
247
|
+
`POST /iam/request-password-reset` is unauthenticated, mints a token and sends mail, and the reset
|
|
248
|
+
path now performs **two** expensive key derivations instead of one (scrypt for IAM plus bcrypt for
|
|
249
|
+
the legacy mirror), each occupying a libuv threadpool slot. The IP-axis limiter is **off unless
|
|
250
|
+
configured**:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
betterAuth: {
|
|
254
|
+
rateLimit: {}, // presence enables it; /request-password-reset gets the halved strict limit
|
|
255
|
+
},
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 4. Client side: use the framework's hashing, never your own
|
|
261
|
+
|
|
262
|
+
The password hashing lives in `@lenne.tech/nuxt-extensions`' auth client, so no call site can forget
|
|
263
|
+
it. Since **`@lenne.tech/nuxt-extensions` 1.16.0** — a different package with its own version
|
|
264
|
+
line, not this one — the composable exposes the full pair:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
const { requestPasswordReset, resetPassword } = useLtAuth();
|
|
268
|
+
|
|
269
|
+
await requestPasswordReset({ email, redirectTo: appUrl('/auth/reset-password') });
|
|
270
|
+
await resetPassword({ newPassword, token });
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Do not hash passwords in project code.** A private `sha256` helper next to a `fetch` call is how a
|
|
274
|
+
project ends up on a path the framework does not cover — and it is exactly how the defect in §2 was
|
|
275
|
+
found in the field.
|
|
276
|
+
|
|
277
|
+
If your project posts to the legacy `POST /users/password/reset-request` / `POST /users/password/reset`
|
|
278
|
+
routes, prefer the IAM pair above. The legacy routes keep working (they are on the user module, not
|
|
279
|
+
the auth module, so §1 does not close them), but they are the deprecated path.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 5. Security headers are now sent by default
|
|
284
|
+
|
|
285
|
+
`@lenne.tech/nest-server` set no browser security headers at all. Projects were rebuilding the same
|
|
286
|
+
set by hand — one inline in `main.ts`, one as its own middleware — and a freshly generated project
|
|
287
|
+
sent none. A missing header produces no error anywhere; it surfaces in a pentest report months
|
|
288
|
+
later.
|
|
289
|
+
|
|
290
|
+
Since 11.38.0 every response carries them, with no configuration:
|
|
291
|
+
|
|
292
|
+
| Header | Value |
|
|
293
|
+
|--------|-------|
|
|
294
|
+
| `X-Content-Type-Options` | `nosniff` |
|
|
295
|
+
| `X-Frame-Options` | `DENY` |
|
|
296
|
+
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
|
297
|
+
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` — **only on an https request** |
|
|
298
|
+
| `X-Powered-By` | removed |
|
|
299
|
+
| `Content-Security-Policy` | **not sent** — opt-in only |
|
|
300
|
+
|
|
301
|
+
They are set as **middleware**, so a request a guard rejects carries them too. That is deliberate:
|
|
302
|
+
a `401` is still a response a browser renders, and those are the responses an attacker generates
|
|
303
|
+
most of.
|
|
304
|
+
|
|
305
|
+
### What you may need to change
|
|
306
|
+
|
|
307
|
+
- **A reverse proxy already setting these** (Traefik, Caddy, nginx): nothing. The proxy writes
|
|
308
|
+
last, so its values win. This layer exists so a deployment *without* one is not bare.
|
|
309
|
+
- **You embed your app in an iframe**: `X-Frame-Options: DENY` will block it. Set
|
|
310
|
+
`security: { headers: { frameOptions: 'SAMEORIGIN' } }` — or `false` for that field.
|
|
311
|
+
- **You want a CSP**: set `contentSecurityPolicy` explicitly. There is no default because a wrong
|
|
312
|
+
one breaks the application rather than hardening it, and this package serves its own HTML from
|
|
313
|
+
the Hub and the GraphQL playground.
|
|
314
|
+
- **Turn everything off**: `security: { headers: false }`.
|
|
315
|
+
|
|
316
|
+
### HSTS never follows configuration
|
|
317
|
+
|
|
318
|
+
It is decided by the request protocol — `x-forwarded-proto` first (the first hop of a chain),
|
|
319
|
+
connection protocol as fallback. There is no flag that switches it on regardless, and that is a
|
|
320
|
+
safety property rather than an omission: a browser **remembers** the header, so one sent from a dev
|
|
321
|
+
server over `http://localhost` makes every project on that host unreachable over http for up to a
|
|
322
|
+
year, and nothing on the server can withdraw it.
|
|
323
|
+
|
|
324
|
+
One consequence worth knowing: behind a TLS-terminating proxy the connection reaching Node is plain
|
|
325
|
+
http, so **`trustProxy` has to be configured** or no production request looks secure and no HSTS is
|
|
326
|
+
sent. `preload` is off by default — it commits every subdomain to a browser-vendor list that is
|
|
327
|
+
awkward to reverse, which is the domain owner's decision.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 6. The password-reset request no longer reveals whether an account exists
|
|
332
|
+
|
|
333
|
+
`POST /users/password/reset-request` answered **404** for an unknown address and **201** for a known
|
|
334
|
+
one — a working oracle for "does this person have an account here". In a multi-tenant product that
|
|
335
|
+
also answers who works at which customer. The framework already answered this correctly on the IAM
|
|
336
|
+
path (Better-Auth returns the same body either way), so the two halves of one framework disagreed
|
|
337
|
+
about the same question.
|
|
338
|
+
|
|
339
|
+
Since 11.38.0 both cases answer identically. `CoreUserService.setPasswordResetTokenForEmail()`
|
|
340
|
+
returns `null` for an unknown address instead of throwing.
|
|
341
|
+
|
|
342
|
+
### The status code is the smaller half — and this affects YOUR code
|
|
343
|
+
|
|
344
|
+
Response **time** separates the two cases far more loudly than the status code: the known path
|
|
345
|
+
sends mail, which is a network round trip orders of magnitude above everything else in the request.
|
|
346
|
+
A fix that changed only the status code would have given up the "unknown address" hint in the UI
|
|
347
|
+
and kept the oracle fully intact.
|
|
348
|
+
|
|
349
|
+
The framework equalises what it controls. The mail send lives in **your** `sendPasswordResetMail()`,
|
|
350
|
+
so that half is yours:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
|
|
354
|
+
const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
|
|
355
|
+
|
|
356
|
+
if (!user) {
|
|
357
|
+
return null; // unknown address — answer exactly as for a known one
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// NOT awaited. Awaiting it makes the known path visibly slower whatever the status code says.
|
|
361
|
+
void this.emailService.sendMail(user.email, 'Password reset', { /* … */ })
|
|
362
|
+
.catch((error: unknown) => this.userServiceLogger.error(`Failed to send: ${String(error)}`));
|
|
363
|
+
|
|
364
|
+
return user;
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The reference implementation is `src/server/modules/user/user.service.ts`; copy that shape.
|
|
369
|
+
|
|
370
|
+
### Migration
|
|
371
|
+
|
|
372
|
+
Two things to check:
|
|
373
|
+
|
|
374
|
+
1. **The return type widened to `null | TUser`.** If you override
|
|
375
|
+
`setPasswordResetTokenForEmail()` or call it directly, handle `null`.
|
|
376
|
+
2. **Your reset form can no longer say "we do not know this address".** That is the only UX cost —
|
|
377
|
+
the endpoint carries no password, so the "wrong password vs unknown address" distinction is not
|
|
378
|
+
affected (that lives at sign-in, governed by the separate `auth.preventUserEnumeration`).
|
|
379
|
+
|
|
380
|
+
To restore the old behaviour:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
auth: {
|
|
384
|
+
passwordReset: { preventUserEnumeration: false },
|
|
385
|
+
},
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## 7. The password-reset mail now links to the app
|
|
391
|
+
|
|
392
|
+
The mail used to carry the link Better-Auth generates, and that points at the **API**:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
https://api.example.com/iam/reset-password/<token>?callbackURL=https://example.com/auth/reset-password
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
It works — the API validates the token and redirects to the app — but it puts a domain the
|
|
399
|
+
recipient does not recognise into a password mail, which is the one thing people are trained to
|
|
400
|
+
check before clicking. Since 11.38.0 the link points at the app:
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
https://example.com/auth/reset-password?token=<token>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### What this gives up, plainly
|
|
407
|
+
|
|
408
|
+
Better-Auth's redirect route validates the token and its expiry **before** forwarding, so an
|
|
409
|
+
expired link produced an error page rather than a form that fails on submit. Linking straight to
|
|
410
|
+
the app moves that error later.
|
|
411
|
+
|
|
412
|
+
It is **not** a security difference: the token ends up in the app URL either way, and the
|
|
413
|
+
`callbackURL` origin check exists only because of the hop that is being removed. If you prefer the
|
|
414
|
+
early error, keep the old behaviour:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
betterAuth: { emailVerification: { passwordResetLink: false } },
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Configuring it
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
betterAuth: {
|
|
424
|
+
emailVerification: {
|
|
425
|
+
// Absolute, relative (resolved against appUrl), or with a {token} placeholder
|
|
426
|
+
passwordResetLink: 'https://example.com/auth/reset-password/{token}',
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`{token}` is substituted wherever it appears — that is what lets a page reading a **path**
|
|
432
|
+
parameter work. Without the placeholder, `?token=` is appended.
|
|
433
|
+
|
|
434
|
+
### Are you affected?
|
|
435
|
+
|
|
436
|
+
- **Your reset page reads `?token=`** (the starter's shape): nothing to do.
|
|
437
|
+
- **Your reset page reads a path parameter**: configure the `{token}` form above, or the link will
|
|
438
|
+
not match your route.
|
|
439
|
+
- **No `appUrl` resolvable**: the old link is kept. A guessed host would 404, which is worse than a
|
|
440
|
+
working link — so this fails back rather than forward.
|
|
441
|
+
|
|
442
|
+
Note this is separate from `email.passwordResetLink`, which serves the legacy
|
|
443
|
+
`/users/password/reset-request` flow and appends the token as a path segment. A project using both
|
|
444
|
+
should point them at the same page.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## 8. Vendor mode: three atomic file sets
|
|
449
|
+
|
|
450
|
+
Projects that vendor `src/core/` into their own tree (no npm dependency, updated via
|
|
451
|
+
`/lt-dev:backend:update-nest-server-core`) sync file by file. This release adds three new files,
|
|
452
|
+
and each one is **already imported** by files it ships alongside — taking the importer without
|
|
453
|
+
its new leaf leaves an unresolvable import.
|
|
454
|
+
|
|
455
|
+
| New file | Existing core files that now import it |
|
|
456
|
+
|---|---|
|
|
457
|
+
| `auth/helpers/legacy-endpoints.helper.ts` | `auth/core-auth.controller.ts`, `auth/core-auth.resolver.ts`, `auth/core-legacy-auth-deprecation.initializer.ts`, `index.ts` |
|
|
458
|
+
| `auth/core-legacy-auth-deprecation.initializer.ts` | `auth/core-auth.module.ts`, `index.ts` |
|
|
459
|
+
| `better-auth/core-better-auth-password-reset.registry.ts` | `better-auth/core-better-auth-api.middleware.ts`, `better-auth/core-better-auth.module.ts` |
|
|
460
|
+
| `common/middlewares/security-headers.middleware.ts` | `core.module.ts`, `index.ts` |
|
|
461
|
+
|
|
462
|
+
**One dependency crosses a module boundary that did not exist before.** `CoreAuthModule` now
|
|
463
|
+
registers `CoreLegacyAuthDeprecationInitializer`, which imports `CoreBetterAuthUserMapper` — so
|
|
464
|
+
`auth/` transitively depends on `better-auth/`. A sync that takes `auth/` but leaves `better-auth/`
|
|
465
|
+
at the old revision will not compile. Take both, or neither.
|
|
466
|
+
|
|
467
|
+
The registry is marked `@internal` because it is not public API — that is not the same as
|
|
468
|
+
optional. It is a mandatory file: two shipped core files import it.
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Compatibility Notes
|
|
473
|
+
|
|
474
|
+
| Pattern | Status | Note |
|
|
475
|
+
|---------|--------|------|
|
|
476
|
+
| `CoreModule.forRoot(envConfig)` (IAM only) | Unaffected | The legacy module is never registered, so §1 cannot apply |
|
|
477
|
+
| `CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(...), envConfig)` | **Check §1** | The default flip lands here |
|
|
478
|
+
| Overriding `checkLegacyGraphQLEnabled()` / `checkLegacyRESTEnabled()` | Compatible | Both still `protected`. Call the exported `isLegacyEndpointEnabled()` rather than re-implementing the table |
|
|
479
|
+
| Overriding `CoreUserService.resetPassword()` / `.update()` | Compatible | Signatures unchanged. A project override that added its own IAM sync as a workaround is now redundant — the second write is equivalent, so it can be dropped without a migration |
|
|
480
|
+
| Subclassing `CoreBetterAuthApiMiddleware` | **Check** | `normalizeResetPassword()` is new and `protected`; the middleware now mutates `req.body` on the three reset routes before forwarding |
|
|
481
|
+
| Setting `betterAuth.options.emailAndPassword` | Compatible | `password` and `onPasswordReset` are protected against being clobbered — see below |
|
|
482
|
+
| Setting `betterAuth.options.emailAndPassword.onPasswordReset` | Compatible, behaviour worth knowing | Yours is **chained after** the framework's legacy mirror rather than replacing it. Both run; neither can silently cancel the other |
|
|
483
|
+
| `LEGACY_AUTH_ENABLED=1` / `=yes` | **Breaking** | The value is matched strictly against `'true'`. A truthy-but-not-`'true'` value now resolves to off |
|
|
484
|
+
| Embedding the app in an iframe | **Check §5** | `X-Frame-Options: DENY` is now sent. Set `frameOptions: 'SAMEORIGIN'` or `false` |
|
|
485
|
+
| A reverse proxy already setting security headers | Compatible | The proxy writes last, so its values win |
|
|
486
|
+
| Overriding `setPasswordResetTokenForEmail()` | **Check §6** | Return type widened to `null | TUser`; handle the unknown-address case |
|
|
487
|
+
| A reset form that says "unknown address" | **Check §6** | The endpoint no longer distinguishes. Set `auth.passwordReset.preventUserEnumeration: false` to restore it |
|
|
488
|
+
| A reset page reading `?token=` | Compatible | The new default link uses exactly that shape |
|
|
489
|
+
| A reset page reading a PATH parameter | **Check §7** | Configure `passwordResetLink: '…/reset-password/{token}'` |
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## Troubleshooting
|
|
494
|
+
|
|
495
|
+
| Symptom | Cause | Fix |
|
|
496
|
+
|---------|-------|-----|
|
|
497
|
+
| `410 Gone` on `/auth/signin` or the `signIn` mutation after upgrading | §1 — the default flipped | Set `auth.legacyEndpoints: { enabled: true }` if you still need Legacy Auth |
|
|
498
|
+
| A user reset their password before upgrading and the new one is refused | The IAM credential kept the old password (§2) | Reset once more; both stores converge |
|
|
499
|
+
| Boot log warns about Legacy Auth on every start | Intended — legacy endpoints are open | Finish the migration, then remove `enabled: true` |
|
|
500
|
+
| `Password reset ... was NOT synced to IAM (no credential account)` | The user exists in Legacy Auth only | Expected while migrating; the account gains a credential on its first IAM sign-in |
|
|
501
|
+
| The app stopped rendering inside an iframe | §5 — `X-Frame-Options: DENY` | `security: { headers: { frameOptions: 'SAMEORIGIN' } }` |
|
|
502
|
+
| No `Strict-Transport-Security` in production | The request does not look secure to the server | Configure `trustProxy` — behind a TLS-terminating proxy the inward connection is plain http (§5) |
|
|
503
|
+
| A reset request for an unknown address returns 201 instead of 404 | §6 — intended | `auth.passwordReset: { preventUserEnumeration: false }` if you need the old behaviour |
|
|
504
|
+
| TypeScript error on `setPasswordResetTokenForEmail()` | §6 — return type widened to `null | TUser` | Handle the `null` case |
|
|
505
|
+
| The reset link in the mail 404s on the app | §7 — the link now points at the app, and your route shape differs | Configure `passwordResetLink` with `{token}`, or set it to `false` |
|
|
506
|
+
|
|
507
|
+
## Module Documentation
|
|
508
|
+
|
|
509
|
+
- [Better-Auth README](../src/core/modules/better-auth/README.md)
|
|
510
|
+
- [Better-Auth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
|
|
511
|
+
- [Module Deprecation Roadmap](../.claude/rules/module-deprecation.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.38.0",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
package/src/config.env.ts
CHANGED
|
@@ -520,6 +520,10 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
520
520
|
maxIterations: 5,
|
|
521
521
|
rateLimit: { max: 20, windowSeconds: 60 },
|
|
522
522
|
},
|
|
523
|
+
// Legacy auth is OFF by default since 11.38.0 (see isLegacyEndpointEnabled). This
|
|
524
|
+
// repository's own `src/server` registers the legacy module and its suites exercise
|
|
525
|
+
// BOTH auth systems, so every non-production environment here opts in explicitly —
|
|
526
|
+
// which is exactly what a project still running legacy now has to do.
|
|
523
527
|
auth: {
|
|
524
528
|
legacyEndpoints: { enabled: true },
|
|
525
529
|
},
|
|
@@ -653,7 +657,11 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
653
657
|
// ===========================================================================
|
|
654
658
|
production: {
|
|
655
659
|
auth: {
|
|
656
|
-
|
|
660
|
+
// Opt-IN since 11.38.0: an unset LEGACY_AUTH_ENABLED now means "no legacy auth",
|
|
661
|
+
// not "legacy auth, because nobody said otherwise". Deployments that still need
|
|
662
|
+
// the legacy endpoints set LEGACY_AUTH_ENABLED=true until their users are migrated
|
|
663
|
+
// (`betterAuthMigrationStatus.canDisableLegacyAuth` says when they are).
|
|
664
|
+
legacyEndpoints: { enabled: process.env.LEGACY_AUTH_ENABLED === 'true' },
|
|
657
665
|
},
|
|
658
666
|
automaticObjectIdFiltering: true,
|
|
659
667
|
baseUrl: process.env.BASE_URL,
|