@lenne.tech/nest-server 11.36.4 → 11.37.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/better-auth.md +32 -0
- package/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/framework-compatibility.md +1 -0
- package/.claude/rules/testing.md +4 -4
- package/.claude/rules/versioning.md +6 -0
- package/CLAUDE.md +22 -5
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -0
- package/dist/core/modules/auth/guards/roles.guard.js +33 -0
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +3 -3
- package/dist/core/modules/error-code/error-codes.js +3 -3
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js +4 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +9 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +25 -1
- package/migration-guides/11.32.x-to-11.33.x.md +37 -4
- package/migration-guides/11.36.4-to-11.36.5.md +184 -0
- package/migration-guides/11.36.x-to-11.37.0.md +344 -0
- package/package.json +17 -4
- package/src/core/modules/ai/core-ai-mcp.controller.ts +9 -3
- package/src/core/modules/auth/guards/roles.guard.ts +68 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +24 -1
- package/src/core/modules/better-auth/README.md +64 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -0
- package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
- package/src/core/modules/error-code/error-codes.ts +13 -3
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +8 -7
- package/src/core/modules/system-setup/README.md +43 -9
- package/src/core/modules/system-setup/core-system-setup.controller.ts +4 -1
- package/src/core/modules/system-setup/core-system-setup.service.ts +54 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.37.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",
|
|
@@ -83,14 +83,13 @@
|
|
|
83
83
|
"url": "https://github.com/lenneTech/nest-server/issues"
|
|
84
84
|
},
|
|
85
85
|
"engines": {
|
|
86
|
-
"node": ">= 22",
|
|
86
|
+
"node": ">= 22.12",
|
|
87
87
|
"pnpm": "^11.0.0"
|
|
88
88
|
},
|
|
89
89
|
"packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25",
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@apollo/server": "5.5.1",
|
|
92
92
|
"@as-integrations/express5": "1.1.2",
|
|
93
|
-
"@better-auth/passkey": "1.6.26",
|
|
94
93
|
"@getbrevo/brevo": "6.0.3",
|
|
95
94
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
96
95
|
"@nestjs/apollo": "13.4.5",
|
|
@@ -108,7 +107,6 @@
|
|
|
108
107
|
"@tus/server": "2.4.4",
|
|
109
108
|
"@types/supertest": "7.2.1",
|
|
110
109
|
"bcrypt": "6.0.0",
|
|
111
|
-
"better-auth": "1.6.26",
|
|
112
110
|
"class-transformer": "0.5.1",
|
|
113
111
|
"class-validator": "0.15.1",
|
|
114
112
|
"compression": "1.8.1",
|
|
@@ -144,7 +142,10 @@
|
|
|
144
142
|
"peerDependencies": {
|
|
145
143
|
"@aws-sdk/client-s3": ">=3.1045.0 <4",
|
|
146
144
|
"@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
|
|
145
|
+
"@better-auth/core": ">=1.7.1 <1.8.0",
|
|
146
|
+
"@better-auth/passkey": ">=1.7.1 <1.8.0",
|
|
147
147
|
"@tus/s3-store": ">=2.0.5 <3",
|
|
148
|
+
"better-auth": ">=1.7.1 <1.8.0",
|
|
148
149
|
"bullmq": ">=5.16.0 <7",
|
|
149
150
|
"ioredis": ">=5.0.0 <7"
|
|
150
151
|
},
|
|
@@ -155,9 +156,18 @@
|
|
|
155
156
|
"@aws-sdk/s3-request-presigner": {
|
|
156
157
|
"optional": true
|
|
157
158
|
},
|
|
159
|
+
"@better-auth/core": {
|
|
160
|
+
"optional": false
|
|
161
|
+
},
|
|
162
|
+
"@better-auth/passkey": {
|
|
163
|
+
"optional": false
|
|
164
|
+
},
|
|
158
165
|
"@tus/s3-store": {
|
|
159
166
|
"optional": true
|
|
160
167
|
},
|
|
168
|
+
"better-auth": {
|
|
169
|
+
"optional": false
|
|
170
|
+
},
|
|
161
171
|
"bullmq": {
|
|
162
172
|
"optional": true
|
|
163
173
|
},
|
|
@@ -168,6 +178,8 @@
|
|
|
168
178
|
"devDependencies": {
|
|
169
179
|
"@aws-sdk/client-s3": "3.1115.0",
|
|
170
180
|
"@aws-sdk/s3-request-presigner": "3.1115.0",
|
|
181
|
+
"@better-auth/core": "1.7.1",
|
|
182
|
+
"@better-auth/passkey": "1.7.1",
|
|
171
183
|
"@compodoc/compodoc": "2.0.0",
|
|
172
184
|
"@nestjs/cli": "11.0.24",
|
|
173
185
|
"@nestjs/schematics": "11.1.0",
|
|
@@ -186,6 +198,7 @@
|
|
|
186
198
|
"@types/passport": "1.0.17",
|
|
187
199
|
"@vitest/coverage-v8": "4.1.11",
|
|
188
200
|
"ansi-colors": "4.1.3",
|
|
201
|
+
"better-auth": "1.7.1",
|
|
189
202
|
"bullmq": "6.2.0",
|
|
190
203
|
"find-file-up": "2.0.1",
|
|
191
204
|
"husky": "9.1.7",
|
|
@@ -18,9 +18,15 @@ import { CoreAiMcpService } from './services/core-ai-mcp.service';
|
|
|
18
18
|
* Exposes the AI tool registry to external MCP clients. The request must carry a
|
|
19
19
|
* valid Bearer token (or session); the handler resolves the user via
|
|
20
20
|
* {@link CoreAiMcpController.resolveUser} (which reuses `req.user` and falls back to
|
|
21
|
-
* verifying the Bearer token directly
|
|
22
|
-
* `req.user`
|
|
23
|
-
*
|
|
21
|
+
* verifying the Bearer token directly). Since 11.36.5 `@Roles(S_EVERYONE)` DOES populate
|
|
22
|
+
* `req.user` — the guard identifies without denying — so the fallback is now a second line
|
|
23
|
+
* of defence rather than the only path. One consequence worth knowing: in a legacy-JWT
|
|
24
|
+
* deployment this endpoint previously answered 401 for everyone, because `req.user` was
|
|
25
|
+
* unset and no other resolution path applied; it now authenticates any holder of a valid
|
|
26
|
+
* legacy access token. That is not an elevation — the server is still built per user with
|
|
27
|
+
* the registry's role filter and the per-owner session binding — but it is a surface that
|
|
28
|
+
* was effectively unreachable before. The MCP session is bound to that user and only their
|
|
29
|
+
* permitted tools are exposed/executed.
|
|
24
30
|
*
|
|
25
31
|
* `@Roles(S_EVERYONE)` lets the request reach the handler (the guard would
|
|
26
32
|
* otherwise reject), and the handler performs the MCP-specific 401 with a
|
|
@@ -147,7 +147,14 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
147
147
|
|
|
148
148
|
// If no roles required, or S_EVERYONE is set, allow access without authentication
|
|
149
149
|
// This allows public endpoints (without @Roles decorator or with S_EVERYONE) to work
|
|
150
|
+
//
|
|
151
|
+
// Access is granted either way, but the caller is still IDENTIFIED when they sent a valid
|
|
152
|
+
// token: returning early here left `request.user` unset, so a signed-in user hitting a
|
|
153
|
+
// public endpoint arrived as anonymous. Endpoints that are public AND personalise for
|
|
154
|
+
// signed-in callers — a search ranking by the caller's own location, a list marking the
|
|
155
|
+
// caller's own entries — silently lost that personalisation, with no error anywhere.
|
|
150
156
|
if (!roles || !roles.some((value) => !!value) || roles.includes(RoleEnum.S_EVERYONE)) {
|
|
157
|
+
await this.tryIdentifyOptionalUser(context);
|
|
151
158
|
return true;
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -339,6 +346,67 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
339
346
|
return user;
|
|
340
347
|
}
|
|
341
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Identify the caller on a PUBLIC endpoint without ever denying access.
|
|
351
|
+
*
|
|
352
|
+
* Mirrors the authentication order of the protected path (Better-Auth first, Passport JWT
|
|
353
|
+
* second) but treats every failure as "anonymous" instead of raising: a missing, expired or
|
|
354
|
+
* malformed token must not turn a public endpoint into a 401. It only sets `request.user`,
|
|
355
|
+
* so `@CurrentUser()` and `serviceOptions.currentUser` see a signed-in caller again.
|
|
356
|
+
*
|
|
357
|
+
* ANONYMOUS MUST STAY ABSENT. Passport's verify callback answers `false` — not `undefined` —
|
|
358
|
+
* when a request carries no usable credentials, and `MixinAuthGuard` assigns that verbatim.
|
|
359
|
+
* Without the normalisation at the end, every anonymous request to a public route would come
|
|
360
|
+
* out of here with `request.user === false` where it previously had no `user` at all. That is
|
|
361
|
+
* invisible to `!!user` and to `user?.id`, and visible to a handler that types the parameter
|
|
362
|
+
* `User`, tests `'user' in request`, or forwards it into `serviceOptions.currentUser`. Pinned
|
|
363
|
+
* by the `userType` assertions in tests/public-endpoint-identity.e2e-spec.ts.
|
|
364
|
+
*/
|
|
365
|
+
protected async tryIdentifyOptionalUser(context: ExecutionContext): Promise<void> {
|
|
366
|
+
try {
|
|
367
|
+
const request = this.getRequest(context);
|
|
368
|
+
if (!request || request.user) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this.resolveServices();
|
|
373
|
+
|
|
374
|
+
if (this.betterAuthService?.isEnabled()) {
|
|
375
|
+
const user = await this.verifyBetterAuthTokenFromContext(context);
|
|
376
|
+
if (user) {
|
|
377
|
+
request.user = user;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const result = super.canActivate(context);
|
|
383
|
+
if (isObservable(result)) {
|
|
384
|
+
await firstValueFrom(result);
|
|
385
|
+
} else {
|
|
386
|
+
await result;
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
// Never denies — but "the caller sent no usable token" and "the strategy itself failed" are
|
|
390
|
+
// not the same thing, and a bare `catch {}` makes them indistinguishable. A rotated secret,
|
|
391
|
+
// an unreachable database inside `validate()`, or a throwing user lookup would degrade every
|
|
392
|
+
// PUBLIC endpoint to anonymous while protected ones 401 — a split brain with nothing in the
|
|
393
|
+
// log to explain it. `debug`, not `warn`: the ordinary case (no credentials, expired token)
|
|
394
|
+
// reaches here too and must not be noise.
|
|
395
|
+
this.logger.debug(
|
|
396
|
+
`Optional identification failed on a public endpoint: ${
|
|
397
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
398
|
+
}`,
|
|
399
|
+
);
|
|
400
|
+
} finally {
|
|
401
|
+
// `finally`, not the try body: the catch above swallows a throw from Passport, and that
|
|
402
|
+
// path can have assigned a falsy user before throwing.
|
|
403
|
+
const request = this.getRequest(context);
|
|
404
|
+
if (request && !request.user && 'user' in request) {
|
|
405
|
+
delete request.user;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
342
410
|
/**
|
|
343
411
|
* Integrate request from GraphQL
|
|
344
412
|
*/
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**For integrating BetterAuth into projects using `@lenne.tech/nest-server`.**
|
|
4
4
|
|
|
5
|
-
> **Estimated time:** 10-15 minutes
|
|
5
|
+
> **Estimated time:** 10-15 minutes (plus Step 0 on a fresh integration)
|
|
6
6
|
|
|
7
7
|
**Need customization?** See [CUSTOMIZATION.md](./CUSTOMIZATION.md) for:
|
|
8
8
|
|
|
@@ -12,6 +12,29 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## Step 0: Install the Peer Dependencies (required from 11.37.0)
|
|
16
|
+
|
|
17
|
+
From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of this package. It is a
|
|
18
|
+
non-optional **peer dependency**, so your project declares and owns the version:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add better-auth@1.7.1 @better-auth/passkey@1.7.1 @better-auth/core@1.7.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
|
|
25
|
+
mixed set fails in ways the type checker does not catch.
|
|
26
|
+
|
|
27
|
+
> **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
|
|
28
|
+
> packages are installed silently at whatever version satisfies `>=1.7.1 <1.8.0` — your build stays
|
|
29
|
+
> green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
|
|
30
|
+
|
|
31
|
+
**Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
|
|
32
|
+
too. When this package owned the version, the API and the app could silently end up on different
|
|
33
|
+
better-auth versions with incompatible payload shapes. Owning it in your own manifest makes that a
|
|
34
|
+
single, visible decision.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
15
38
|
## Choose Your Scenario
|
|
16
39
|
|
|
17
40
|
| Scenario | Use When | CoreModule Signature | Steps |
|
|
@@ -30,6 +30,7 @@ const config = {
|
|
|
30
30
|
- [Features](#features)
|
|
31
31
|
- [Quick Integration](#quick-integration-for-claude-code--ai-assistants)
|
|
32
32
|
- [Project Integration Guide](#project-integration-guide-required-steps)
|
|
33
|
+
- [Boot-Time Behaviour](#boot-time-behaviour)
|
|
33
34
|
- [Configuration](#configuration)
|
|
34
35
|
- [REST API Endpoints](#rest-api-endpoints)
|
|
35
36
|
- [GraphQL API](#graphql-api)
|
|
@@ -1015,6 +1016,61 @@ const config = {
|
|
|
1015
1016
|
- Full flexibility for specialized plugins
|
|
1016
1017
|
- No package updates needed for new Better-Auth plugins
|
|
1017
1018
|
|
|
1019
|
+
## Boot-Time Behaviour
|
|
1020
|
+
|
|
1021
|
+
`CoreBetterAuthService.onModuleInit()` runs two steps against the database before the application
|
|
1022
|
+
starts listening. Both are `protected`, so a subclass can override either — **an override of
|
|
1023
|
+
`onModuleInit()` that does not call `super.onModuleInit()` skips both.**
|
|
1024
|
+
|
|
1025
|
+
### 1. `ensureIndices()` — performance
|
|
1026
|
+
|
|
1027
|
+
Idempotent indices on `session`, `users`, `account` and `verification`, including a TTL index that
|
|
1028
|
+
expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
|
|
1029
|
+
not block the boot: these help speed, not correctness.
|
|
1030
|
+
|
|
1031
|
+
### 2. `backfillAccountIssuers()` — correctness (11.37.0+)
|
|
1032
|
+
|
|
1033
|
+
From better-auth 1.7 an account is keyed by `(issuer, accountId)` and the sign-in route filters on
|
|
1034
|
+
it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing password
|
|
1035
|
+
user would be locked out by the upgrade**, with a bare 401 and nothing in the log to explain it.
|
|
1036
|
+
This step repairs those rows on the first boot after the upgrade.
|
|
1037
|
+
|
|
1038
|
+
| Property | Behaviour |
|
|
1039
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1040
|
+
| Scope | Credential accounts only. OAuth/SSO rows are reported, never rewritten — see below |
|
|
1041
|
+
| Idempotency | Structural: only rows missing the field are selected |
|
|
1042
|
+
| Repeat cost | A completion marker in `better-auth-backfills` short-circuits later boots to one `_id` lookup. Neither backfill query is indexable, so without it every boot would scan the whole collection |
|
|
1043
|
+
| Schema overrides | Collection and field names are resolved from the running better-auth instance; a customised name is logged at `warn` |
|
|
1044
|
+
| Partial failure | The write is unordered, so one bad row cannot shadow the rest. The marker is **not** written, and the next boot retries |
|
|
1045
|
+
| Failure | Logged at `error`; the boot continues. A server that starts with a loud error beats one that will not start |
|
|
1046
|
+
|
|
1047
|
+
**Log lines to expect on the first boot after upgrading:**
|
|
1048
|
+
|
|
1049
|
+
| Line | Meaning |
|
|
1050
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
1051
|
+
| `Backfilled account.issuer on N credential account(s)` | Success — those N users can sign in again |
|
|
1052
|
+
| `Backfilled N/M credential accounts. X user(s) CANNOT sign in` | Partial. Usually a duplicate `(issuer, accountId)` pair from an earlier migration. Resolve and restart |
|
|
1053
|
+
| `Could not backfill the account issuer: …` | The operation failed entirely. Affected users cannot sign in until it succeeds |
|
|
1054
|
+
| `At least one non-credential account has no "issuer"` | See below — act before the next social sign-in |
|
|
1055
|
+
| `Backfilling the account issuer against a customised schema` | You renamed the account model or issuer field |
|
|
1056
|
+
|
|
1057
|
+
**Why OAuth/SSO rows are not backfilled.** `local:credential` is a pure function of the provider id
|
|
1058
|
+
and therefore derivable. An OAuth issuer is not — it is either the provider's real OIDC issuer or
|
|
1059
|
+
the synthetic `local:oauth:<providerId>` fallback, decided per provider. Guessing would not fail
|
|
1060
|
+
loudly: better-auth falls back to matching the user by the provider-asserted email and implicitly
|
|
1061
|
+
links a **second** account row; if that email has changed, it creates a **new user** and orphans the
|
|
1062
|
+
old account together with its provider tokens, which no unlink will ever remove. So those rows are
|
|
1063
|
+
reported and the decision stays with the project. Set the issuer per provider **before** the first
|
|
1064
|
+
social sign-in after the upgrade — see
|
|
1065
|
+
[migration-guides/11.36.x-to-11.37.0.md](../../../../migration-guides/11.36.x-to-11.37.0.md) §4.
|
|
1066
|
+
|
|
1067
|
+
> **Do not add `issuer` to this package's own reads of the `account` collection**
|
|
1068
|
+
> (`syncPasswordChangeToIam`, `migrateAccountToIam`, `getMigrationStatus`). They filter on
|
|
1069
|
+
> `providerId` alone on purpose, so they keep working on rows the backfill has not reached yet —
|
|
1070
|
+
> which matters most when the backfill has failed.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1018
1074
|
## Module Setup
|
|
1019
1075
|
|
|
1020
1076
|
See the [Project Integration Guide](#project-integration-guide-required-steps) at the top of this document for complete step-by-step instructions.
|
|
@@ -1895,6 +1951,14 @@ When a user is deleted:
|
|
|
1895
1951
|
db.account.findOne({ userId: ObjectId('...'), providerId: 'credential' });
|
|
1896
1952
|
```
|
|
1897
1953
|
|
|
1954
|
+
From better-auth 1.7 the row must ALSO carry `issuer: 'local:credential'`. A row matching only
|
|
1955
|
+
`providerId` is not usable — better-auth keys accounts by `(issuer, accountId)` and will not find
|
|
1956
|
+
it, so sign-in answers 401 while this query happily reports that the account exists:
|
|
1957
|
+
|
|
1958
|
+
```javascript
|
|
1959
|
+
db.account.findOne({ issuer: 'local:credential', providerId: 'credential', userId: ObjectId('...') });
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1898
1962
|
3. Check server logs for sync warnings:
|
|
1899
1963
|
```
|
|
1900
1964
|
[CoreUserService] Failed to sync password to IAM...
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
1
2
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
3
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
4
|
import * as bcrypt from 'bcrypt';
|
|
@@ -509,10 +510,16 @@ export class CoreBetterAuthUserMapper {
|
|
|
509
510
|
// Store account matching Better-Auth's format:
|
|
510
511
|
// - userId: ObjectId referencing users._id
|
|
511
512
|
// - accountId: string version of users._id
|
|
513
|
+
// - issuer: from better-auth >= 1.7 accounts are keyed by (issuer,
|
|
514
|
+
// accountId), and credential accounts carry a synthetic issuer. Writing
|
|
515
|
+
// the row without it produces an account better-auth cannot find, so the
|
|
516
|
+
// migrated user gets a 401 on the very sign-in that triggered the
|
|
517
|
+
// migration. Always derive it from the helper, never hand-write it.
|
|
512
518
|
await accountsCollection.insertOne({
|
|
513
519
|
accountId: userIdHex,
|
|
514
520
|
createdAt: now,
|
|
515
521
|
id: this.generateId(),
|
|
522
|
+
issuer: createLocalAccountIssuer('credential'),
|
|
516
523
|
password: passwordHash,
|
|
517
524
|
providerId: 'credential',
|
|
518
525
|
updatedAt: now,
|
|
@@ -71,3 +71,29 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
71
71
|
* Declared `@Optional()` in the CoreBetterAuthService constructor.
|
|
72
72
|
*/
|
|
73
73
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* better-auth's default name for the account collection, and for the `issuer` field inside it.
|
|
77
|
+
*
|
|
78
|
+
* Both are overridable by a consumer through `betterAuth.options.account.modelName` /
|
|
79
|
+
* `.fields.issuer`, which `better-auth.config.ts` spreads onto the resolved config verbatim. Any
|
|
80
|
+
* framework code touching that collection directly MUST resolve the real names from the running
|
|
81
|
+
* instance and fall back to these — a hardcoded name silently addresses a collection better-auth
|
|
82
|
+
* does not use, which on the issuer backfill means every password user stays locked out while the
|
|
83
|
+
* operation reports success by saying nothing.
|
|
84
|
+
*/
|
|
85
|
+
export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
|
|
86
|
+
export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collection holding one-shot completion markers for boot-time data migrations, and the marker id
|
|
90
|
+
* of the `account.issuer` backfill (better-auth 1.7).
|
|
91
|
+
*
|
|
92
|
+
* The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
|
|
93
|
+
* but its filters (`$exists: false`, `$ne`) cannot use an index, so without a marker every boot of
|
|
94
|
+
* every replica pays a full pass over the account collection — forever, and inside the `await` that
|
|
95
|
+
* precedes `app.listen()`. Versioned in the id so a future backfill of the same field can run
|
|
96
|
+
* again without clearing this one.
|
|
97
|
+
*/
|
|
98
|
+
export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
|
|
99
|
+
export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
1
2
|
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
|
2
3
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
4
|
import { Request } from 'express';
|
|
@@ -14,7 +15,15 @@ import { BetterAuthInstance } from './better-auth.config';
|
|
|
14
15
|
import { isJwtShaped } from './core-better-auth-token.helper';
|
|
15
16
|
import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
|
|
16
17
|
import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ACCOUNT_ISSUER_BACKFILL_ID,
|
|
20
|
+
BACKFILL_MARKER_COLLECTION,
|
|
21
|
+
BETTER_AUTH_CONFIG,
|
|
22
|
+
BETTER_AUTH_COOKIE_DOMAIN,
|
|
23
|
+
BETTER_AUTH_INSTANCE,
|
|
24
|
+
DEFAULT_ACCOUNT_ISSUER_FIELD,
|
|
25
|
+
DEFAULT_ACCOUNT_MODEL_NAME,
|
|
26
|
+
} from './core-better-auth.constants';
|
|
18
27
|
|
|
19
28
|
/**
|
|
20
29
|
* Result of a session validation
|
|
@@ -94,12 +103,31 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
106
|
+
* Boot steps for the better-auth integration.
|
|
107
|
+
*
|
|
108
|
+
* Two jobs live here and they are deliberately separate methods: ensuring indices is a
|
|
109
|
+
* PERFORMANCE concern that repeats forever and degrades gracefully (hence `warn`), while the
|
|
110
|
+
* issuer backfill is a CORRECTNESS concern that should happen once and locks users out when it
|
|
111
|
+
* does not (hence `error`). Keeping them in one method made those two severities look like one
|
|
112
|
+
* decision.
|
|
113
|
+
*
|
|
114
|
+
* Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
|
|
115
|
+
* below, so indices come first.
|
|
99
116
|
*/
|
|
100
117
|
async onModuleInit(): Promise<void> {
|
|
101
118
|
if (!this.isEnabled() || !this.connection?.db) return;
|
|
102
119
|
|
|
120
|
+
await this.ensureIndices();
|
|
121
|
+
await this.backfillAccountIssuers();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ensure performance indices exist on the session, users, account and verification collections.
|
|
126
|
+
* Indices are idempotent — calling createIndex on an existing index is a no-op.
|
|
127
|
+
*/
|
|
128
|
+
protected async ensureIndices(): Promise<void> {
|
|
129
|
+
if (!this.connection?.db) return;
|
|
130
|
+
|
|
103
131
|
try {
|
|
104
132
|
const db = this.connection.db;
|
|
105
133
|
|
|
@@ -130,6 +158,154 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
130
158
|
}
|
|
131
159
|
}
|
|
132
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Backfills `account.issuer` on rows written before better-auth 1.7.
|
|
163
|
+
*
|
|
164
|
+
* From 1.7 an account is keyed by (issuer, accountId), and sign-in filters on
|
|
165
|
+
* it verbatim:
|
|
166
|
+
*
|
|
167
|
+
* account.providerId === 'credential' && account.issuer === credentialIssuer
|
|
168
|
+
*
|
|
169
|
+
* A row written by 1.6 has no `issuer` at all, so that comparison can never
|
|
170
|
+
* hold: every existing password user would be locked out by the upgrade
|
|
171
|
+
* itself, with a 401 and nothing in the logs to explain it. This closes that
|
|
172
|
+
* gap on the first boot after the upgrade.
|
|
173
|
+
*
|
|
174
|
+
* Idempotent — the filter only matches rows still missing the field, so the
|
|
175
|
+
* second start updates nothing.
|
|
176
|
+
*
|
|
177
|
+
* Scope is deliberately limited to credential accounts, where the issuer is
|
|
178
|
+
* derivable without guessing. OAuth accounts are NOT touched: their issuer
|
|
179
|
+
* depends on the provider (a real OIDC issuer, or the synthetic
|
|
180
|
+
* `local:oauth:<id>` fallback), and writing the wrong one would not fail
|
|
181
|
+
* loudly — it would create a second account on the next social sign-in. Those
|
|
182
|
+
* rows are reported instead, so the decision stays with the project.
|
|
183
|
+
*
|
|
184
|
+
* ONE THING NOT TO "TIDY UP": this package's own reads of the `account`
|
|
185
|
+
* collection filter on `providerId` alone — `syncPasswordChangeToIam`,
|
|
186
|
+
* `migrateAccountToIam` and `getMigrationStatus` in
|
|
187
|
+
* core-better-auth-user.mapper.ts. Adding `issuer` to those filters looks like
|
|
188
|
+
* consistency and is a regression. This backfill is deliberately NON-FATAL: if
|
|
189
|
+
* it fails, the server still boots and logs an error. Reads that do not require
|
|
190
|
+
* the field keep working on un-backfilled rows — `getMigrationStatus` would
|
|
191
|
+
* otherwise report zero migrated users, and `syncPasswordChangeToIam` would
|
|
192
|
+
* stop finding the account it is meant to update. Only better-auth's own
|
|
193
|
+
* sign-in path needs the issuer, and that one is better-auth's code, not ours.
|
|
194
|
+
*/
|
|
195
|
+
protected async backfillAccountIssuers(): Promise<void> {
|
|
196
|
+
if (!this.isEnabled() || !this.connection?.db) return;
|
|
197
|
+
|
|
198
|
+
const db = this.connection.db;
|
|
199
|
+
|
|
200
|
+
// The consumer can rename both of these through `betterAuth.options.account`, which
|
|
201
|
+
// better-auth.config.ts spreads onto the resolved config verbatim. Hardcoding them turns this
|
|
202
|
+
// whole method into a silent no-op on such a project: nothing matches, `modifiedCount` is 0,
|
|
203
|
+
// neither log line fires, and every password user is locked out while the operator's upgrade
|
|
204
|
+
// checklist is satisfied by silence in both directions.
|
|
205
|
+
const accountOptions = (this.authInstance as any)?.options?.account;
|
|
206
|
+
const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
|
|
207
|
+
const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const accounts = db.collection(modelName);
|
|
211
|
+
|
|
212
|
+
// A completion marker, checked before anything expensive. Without it both queries below run
|
|
213
|
+
// on EVERY boot of EVERY replica, forever — and neither is indexable (`$exists: false` cannot
|
|
214
|
+
// appear in a partialFilterExpression, `$ne` is not selective), so each is a full pass over
|
|
215
|
+
// the account collection, awaited before the app starts listening. With the marker the steady
|
|
216
|
+
// state is a single `_id` lookup. `system-setup-locks` in CoreSystemSetupService is the
|
|
217
|
+
// precedent for this kind of once-per-deployment boot state.
|
|
218
|
+
const markers = db.collection(BACKFILL_MARKER_COLLECTION);
|
|
219
|
+
if (await markers.findOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any })) {
|
|
220
|
+
this.logger.debug(`account.${issuerField} backfill already completed — skipping.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (modelName !== DEFAULT_ACCOUNT_MODEL_NAME || issuerField !== DEFAULT_ACCOUNT_ISSUER_FIELD) {
|
|
225
|
+
this.logger.warn(
|
|
226
|
+
`Backfilling the account issuer against a customised schema (collection "${modelName}", ` +
|
|
227
|
+
`field "${issuerField}"). Verify these match what better-auth actually writes.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const pending = await accounts
|
|
232
|
+
.find({ [issuerField]: { $exists: false }, providerId: 'credential' }, { projection: { _id: 1 } })
|
|
233
|
+
.toArray();
|
|
234
|
+
|
|
235
|
+
let backfilled = 0;
|
|
236
|
+
|
|
237
|
+
if (pending.length) {
|
|
238
|
+
// `ordered: false` is load-bearing. better-auth declares a UNIQUE index on
|
|
239
|
+
// (issuer, accountId); a single pre-existing duplicate would abort an ordered write and
|
|
240
|
+
// leave every row after it untouched — those users stay locked out, with one log line and
|
|
241
|
+
// a green boot. Unordered, one bad row costs only itself.
|
|
242
|
+
const result = await accounts.bulkWrite(
|
|
243
|
+
pending.map((doc) => ({
|
|
244
|
+
updateOne: {
|
|
245
|
+
filter: { _id: doc._id },
|
|
246
|
+
update: { $set: { [issuerField]: createLocalAccountIssuer('credential') } },
|
|
247
|
+
},
|
|
248
|
+
})),
|
|
249
|
+
{ ordered: false },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
backfilled = result.modifiedCount;
|
|
253
|
+
|
|
254
|
+
if (backfilled > 0) {
|
|
255
|
+
this.logger.log(
|
|
256
|
+
`Backfilled account.${issuerField} on ${backfilled} credential account(s) for better-auth >= 1.7. ` +
|
|
257
|
+
'Without it these users could not sign in.',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (backfilled < pending.length) {
|
|
262
|
+
// Do NOT write the marker in this case — the next boot must retry the remainder.
|
|
263
|
+
this.logger.error(
|
|
264
|
+
`Backfilled ${backfilled}/${pending.length} credential accounts. ` +
|
|
265
|
+
`${pending.length - backfilled} user(s) CANNOT sign in until this is resolved manually. ` +
|
|
266
|
+
'The most likely cause is a duplicate (issuer, accountId) pair from an earlier partial migration.',
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
this.logger.debug(`No account.${issuerField} backfill needed — no credential rows predate better-auth 1.7.`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Only a complete run earns the marker.
|
|
275
|
+
await markers.updateOne(
|
|
276
|
+
{ _id: ACCOUNT_ISSUER_BACKFILL_ID as any },
|
|
277
|
+
{ $set: { backfilled, completedAt: new Date(), issuerField, modelName } },
|
|
278
|
+
{ upsert: true },
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Existence probe rather than a count: `$ne` cannot use an index, so counting to the end is a
|
|
282
|
+
// guaranteed collection scan for a log line. "At least one" is all the message needs to say.
|
|
283
|
+
const staleOther = await accounts.findOne(
|
|
284
|
+
{ [issuerField]: { $exists: false }, providerId: { $ne: 'credential' } },
|
|
285
|
+
{ projection: { _id: 1 } },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (staleOther) {
|
|
289
|
+
this.logger.warn(
|
|
290
|
+
`At least one non-credential account has no "${issuerField}". better-auth >= 1.7 will not match it by ` +
|
|
291
|
+
'(issuer, accountId) — and such a sign-in does NOT simply fail: it falls back to matching the user by ' +
|
|
292
|
+
'the provider-asserted email and implicitly links a SECOND account row. If the provider-side email has ' +
|
|
293
|
+
'changed, a NEW user is created instead and the existing account is orphaned, together with the provider ' +
|
|
294
|
+
'tokens on the old row, which no unlink will ever remove. Set the issuer per provider BEFORE the first ' +
|
|
295
|
+
"social sign-in after this upgrade: the provider's real OIDC issuer, or the synthetic " +
|
|
296
|
+
'"local:oauth:<providerId>".',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
// Correctness, not performance — say so loudly, but do not stop the boot:
|
|
301
|
+
// a server that starts with a warning beats one that will not start at all.
|
|
302
|
+
this.logger.error(
|
|
303
|
+
`Could not backfill the account issuer: ${error instanceof Error ? error.message : 'unknown'}. ` +
|
|
304
|
+
'Existing password users may be unable to sign in until this succeeds.',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
133
309
|
/**
|
|
134
310
|
* Checks if better-auth is enabled and initialized
|
|
135
311
|
* Returns true only if:
|
|
@@ -468,12 +468,22 @@ export const LtnsErrors = {
|
|
|
468
468
|
// System Setup Errors (LTNS_0050-LTNS_0059)
|
|
469
469
|
// =====================================================
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Raised by BOTH guards in `createInitialAdmin()`, which is why the message names both.
|
|
473
|
+
*
|
|
474
|
+
* The first guard refuses because users exist. The second refuses because the initial-admin
|
|
475
|
+
* claim in `system-setup-locks` is held — and that marker is removed only when creation FAILS,
|
|
476
|
+
* so it OUTLIVES a successful setup by design. A test suite that resets the database by
|
|
477
|
+
* emptying `users` therefore still hits the claim, and until 11.36.5 was told "users already
|
|
478
|
+
* exist" while the users collection was empty. That mismatch cost a consumer project a full CI
|
|
479
|
+
* cycle to diagnose, so the message now covers both conditions.
|
|
480
|
+
*/
|
|
471
481
|
SYSTEM_SETUP_NOT_AVAILABLE: {
|
|
472
482
|
code: 'LTNS_0050',
|
|
473
|
-
message: 'System setup not available - users already exist',
|
|
483
|
+
message: 'System setup not available - users already exist, or the initial-admin setup is claimed',
|
|
474
484
|
translations: {
|
|
475
|
-
de: 'System-Setup nicht verfügbar - es existieren bereits Benutzer.',
|
|
476
|
-
en: 'System setup not available - users already exist.',
|
|
485
|
+
de: 'System-Setup nicht verfügbar - es existieren bereits Benutzer, oder das Initial-Admin-Setup ist bereits beansprucht.',
|
|
486
|
+
en: 'System setup not available - users already exist, or the initial-admin setup is claimed.',
|
|
477
487
|
},
|
|
478
488
|
},
|
|
479
489
|
|
|
@@ -92,13 +92,14 @@ export class SystemSetupController extends CoreSystemSetupController {
|
|
|
92
92
|
|
|
93
93
|
## Common Mistakes
|
|
94
94
|
|
|
95
|
-
| Mistake | Symptom | Fix
|
|
96
|
-
| ------------------------------------------- | -------------------------------- |
|
|
97
|
-
| BetterAuth not enabled | 404 on endpoints or 403 on init | Ensure `betterAuth` is configured
|
|
98
|
-
| Calling init with existing users | 403 "System setup not available" | Init
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
95
|
+
| Mistake | Symptom | Fix |
|
|
96
|
+
| ------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
97
|
+
| BetterAuth not enabled | 404 on endpoints or 403 on init | Ensure `betterAuth` is configured |
|
|
98
|
+
| Calling init with existing users | 403 "System setup not available" | Init requires an empty `users` collection |
|
|
99
|
+
| Re-running init after a successful setup | 403, even with an empty `users` | The claim marker in `system-setup-locks` outlives a SUCCESSFUL setup (it is released only on failure) — drop that collection too, or wait 5 min for it to go stale |
|
|
100
|
+
| Password too short | 400 validation error | Password must be at least 8 characters |
|
|
101
|
+
| Missing ENV password | Auto-creation silently skipped | Set both `email` and `password` ENV vars |
|
|
102
|
+
| `systemSetup: { enabled: false }` in config | 404 on endpoints | Remove the explicit disable |
|
|
102
103
|
|
|
103
104
|
---
|
|
104
105
|
|