@lenne.tech/nest-server 11.36.2 → 11.36.4

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.
Files changed (35) hide show
  1. package/.claude/rules/better-auth.md +54 -0
  2. package/.claude/rules/configurable-features.md +1 -1
  3. package/.claude/rules/testing.md +88 -4
  4. package/CLAUDE.md +1 -0
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/modules/better-auth/better-auth.types.d.ts +4 -0
  7. package/dist/core/modules/better-auth/better-auth.types.js +9 -0
  8. package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
  9. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +1 -1
  10. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +16 -6
  11. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-token.helper.d.ts +1 -0
  13. package/dist/core/modules/better-auth/core-better-auth-token.helper.js +4 -0
  14. package/dist/core/modules/better-auth/core-better-auth-token.helper.js.map +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
  16. package/dist/core/modules/better-auth/core-better-auth.controller.js +9 -4
  17. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  18. package/dist/core/modules/better-auth/core-better-auth.resolver.js +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  20. package/dist/core/modules/better-auth/core-better-auth.service.js +2 -1
  21. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  22. package/dist/tsconfig.build.tsbuildinfo +1 -1
  23. package/docs/REQUEST-LIFECYCLE.md +8 -0
  24. package/migration-guides/11.36.2-to-11.36.3.md +131 -0
  25. package/migration-guides/11.36.3-to-11.36.4.md +145 -0
  26. package/package.json +1 -1
  27. package/src/core/modules/better-auth/CUSTOMIZATION.md +25 -0
  28. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +6 -0
  29. package/src/core/modules/better-auth/README.md +4 -4
  30. package/src/core/modules/better-auth/better-auth.types.ts +30 -0
  31. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +49 -7
  32. package/src/core/modules/better-auth/core-better-auth-token.helper.ts +16 -0
  33. package/src/core/modules/better-auth/core-better-auth.controller.ts +53 -7
  34. package/src/core/modules/better-auth/core-better-auth.resolver.ts +6 -1
  35. package/src/core/modules/better-auth/core-better-auth.service.ts +5 -2
@@ -1460,6 +1460,14 @@ All security features are configured in `config.env.ts` under the `security` key
1460
1460
  | JWT-only | `cookies: false` | Yes | No | Yes |
1461
1461
  | Hybrid | `cookies: { exposeTokenInBody: true }` | Yes | Yes | Yes |
1462
1462
 
1463
+ > **In hybrid mode the body token and the cookie are DIFFERENT values.** With the JWT plugin active,
1464
+ > the body carries a JWT while the cookie keeps the opaque Better-Auth session token — Better-Auth
1465
+ > resolves a session by that opaque value, so a JWT in the cookie authenticates nothing. Treating
1466
+ > them as one token is what caused 11.36.3, where sign-in succeeded and every following request was
1467
+ > anonymous. `setSessionCookies()` now refuses a JWT-shaped cookie value. Hybrid mode is confined to
1468
+ > development and CI: `assertCookiesProductionSafe()` forbids `exposeTokenInBody` in `production`
1469
+ > and `staging`.
1470
+
1463
1471
  ### Guardian Gates
1464
1472
 
1465
1473
  | Config Path | Type | Default | Description |
@@ -0,0 +1,131 @@
1
+ # Migration Guide: 11.36.2 → 11.36.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | None |
9
+ | **Bugfixes** | **The session cookie carried the wrong token in hybrid mode** — sign-in succeeded and every following request was anonymous (§1). Development and CI only |
10
+ | **Migration Effort** | **None for most projects.** `pnpm update`. Read §2 only if you OVERRIDE `processAuthResult()` or `processCookies()` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.3
18
+ ```
19
+
20
+ **Vendor-mode projects:** the fix spans four files that form an ATOMIC set — syncing a subset
21
+ produces a TypeScript build failure, because the cookie helper and the controller both now import
22
+ `isJwtShaped` from the token helper, and the controller imports `hasToken` from the types module:
23
+
24
+ - `src/core/modules/better-auth/better-auth.types.ts`
25
+ - `src/core/modules/better-auth/core-better-auth-token.helper.ts`
26
+ - `src/core/modules/better-auth/core-better-auth-cookie.helper.ts`
27
+ - `src/core/modules/better-auth/core-better-auth.controller.ts`
28
+
29
+ ---
30
+
31
+ ## 1. Session cookie no longer carries the body's JWT
32
+
33
+ **Were you affected?** Only if all three of these are true:
34
+
35
+ 1. `cookies.exposeTokenInBody: true`, **and**
36
+ 2. the Better-Auth JWT plugin is enabled, **and**
37
+ 3. the environment is `local`, `development`, `ci` or `e2e`.
38
+
39
+ **Production and staging were never affected.** `assertCookiesProductionSafe()` throws at boot when
40
+ `exposeTokenInBody` is set in either, so no live deployment could reach this state. If you are
41
+ upgrading because you read "session cookie bug", this is a development and CI fix — no emergency.
42
+
43
+ **The symptom, if you had it:** sign-in returns `200` and sets a cookie, and the very next request is
44
+ anonymous. `GET /iam/session` answers `success: false` for a session that plainly exists in the
45
+ database. In a browser the user is signed out again the moment they navigate. Cookie-based E2E
46
+ suites fail in CI while passing locally — because in this framework's own `config.env.ts` only `ci`
47
+ and `e2e` set `exposeTokenInBody`, while `local`, `development` and `production` leave it unset. In
48
+ cookies-only mode both carriers hold the same value, so the defect is invisible there. Check your own
49
+ config rather than assuming these defaults.
50
+
51
+ **The cause.** With `exposeTokenInBody`, `resolveJwtToken()` converts the opaque session token into a
52
+ JWT for the response body — correct, that is what a bearer client wants. The cookie was then written
53
+ with that same JWT. Better-Auth resolves a session by the **opaque** token it stored, so a JWT there
54
+ matches nothing.
55
+
56
+ **What changed.** The cookie now receives the opaque session token while the body keeps the JWT.
57
+ Beyond the fix itself, `setSessionCookies()` now **refuses** a JWT-shaped value outright and logs an
58
+ error, so the same mistake can no longer be made silently by any future call site or by your own
59
+ override.
60
+
61
+ Nothing to configure. If you work around this today by disabling `exposeTokenInBody` in CI, you can
62
+ undo that.
63
+
64
+ ---
65
+
66
+ ## 2. If you override `processAuthResult()` or a sign-in handler (action required)
67
+
68
+ `BetterAuthCookieHelper.processAuthResult()` takes a fifth parameter, `sessionToken`, since 11.36.3:
69
+
70
+ ```typescript
71
+ processAuthResult(res, result, cookiesEnabled, exposeTokenInBody, sessionToken?)
72
+ ```
73
+
74
+ **TypeScript permits an override with fewer parameters.** A subclass still declaring the old
75
+ four-argument signature compiles cleanly and silently drops the session token. Check yours:
76
+
77
+ ```bash
78
+ grep -rn "processAuthResult\|processCookies" src/
79
+ ```
80
+
81
+ Since 11.36.3 that mistake is no longer silent — `setSessionCookies()` refuses the JWT and logs an
82
+ error, so you lose the cookie rather than getting one that authenticates nothing. Still worth fixing:
83
+ add the parameter and pass it through.
84
+
85
+ **Deriving the value yourself?** Use `this.sessionTokenForCookie(response)` rather than reading
86
+ `response.session?.token`. `session.token` is optional on the `hasSession()` type guard, and
87
+ `api.signInEmail()` returns the token at the **top level with no `session` object at all** — so the
88
+ obvious expression yields `undefined` in exactly the case that matters, and the cookie falls back to
89
+ the JWT.
90
+
91
+ ---
92
+
93
+ ## 3. Tooling, framework repo only
94
+
95
+ Not shipped behaviour; relevant if you maintain a fork of this repo's tooling.
96
+
97
+ `scripts/check-mutations.mjs` gained `--no-infra` (the mutations that need no MongoDB) and
98
+ `--since=<ref>` (mutations touching files changed since a ref). Both are for local work and print
99
+ what they did **not** check. `--since` is a heuristic — it does not follow transitive imports — and
100
+ is deliberately kept off the publish path.
101
+
102
+ Several defects in that script were fixed in the same pass, all of the same family: a non-zero exit
103
+ with no reported test failures is now **INCONCLUSIVE** rather than "evidence confirmed"; the parallel
104
+ path no longer disables the e2e starvation guards it itself triggers; the worktree-failure fallback
105
+ no longer runs every mutation concurrently against the real working tree; the race guard no longer
106
+ overwrites the concurrent edit it was written to detect; `--since` no longer accepts a value git
107
+ would read as an option (`--output=<path>` truncated arbitrary files); and an empty selection exits
108
+ `2` instead of `0`, so no wrapper can read "nothing ran" as "everything passed".
109
+
110
+ ---
111
+
112
+ ## Module Documentation
113
+
114
+ The better-auth module's own docs were updated in the same pass — read these if §2 applies to you:
115
+
116
+ | Document | What changed |
117
+ |----------|--------------|
118
+ | [`better-auth/CUSTOMIZATION.md`](../src/core/modules/better-auth/CUSTOMIZATION.md) | New section *"Session cookie vs. body token — do not conflate them"*: which carrier holds which value, why `sessionTokenForCookie()` is the right source in an override, and the fifth-parameter warning |
119
+ | [`better-auth/README.md`](../src/core/modules/better-auth/README.md) | The `cookies` row now states that the body token is a JWT when the JWT plugin is active, and that this is NOT the value the session cookie carries |
120
+ | [`better-auth/INTEGRATION-CHECKLIST.md`](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) | §6 (the `ci` / `e2e` config block that turns `exposeTokenInBody` on) now points at the CUSTOMIZATION section above |
121
+ | [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md) | The auth-modes table gained a note on the hybrid row |
122
+
123
+ ---
124
+
125
+ ## Troubleshooting
126
+
127
+ | Symptom | Cause | Fix |
128
+ |---------|-------|-----|
129
+ | `Refusing to write a JWT into the session cookie` in the log | An override or custom handler passed the body token | §2 — pass the opaque session token, or use `sessionTokenForCookie()` |
130
+ | Cookie auth worked before the upgrade, now no cookie is set | Same as above, previously silent | §2 |
131
+ | `check:mutations` reports INCONCLUSIVE | The run failed without reporting failing tests — crash, timeout or starvation | Re-run that mutation alone; it is no longer counted as evidence |
@@ -0,0 +1,145 @@
1
+ # Migration Guide: 11.36.3 → 11.36.4
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | None |
9
+ | **Bugfixes** | **The test-database drop guard accepted ordinary project databases** — a name merely CONTAINING `ci`/`test` read as disposable, so an e2e run started from an `lt dev` shell could drop the developer's DEVELOPMENT database (§1). Framework-internal, but every starter-derived project carries a private copy of the same code |
10
+ | **Migration Effort** | **None** for the framework itself — `tests/` is not published. **~1 minute, recommended**, if your project was generated from `nest-server-starter`: apply the one-line fix in §1 to your own `tests/db-lifecycle.reporter.ts` |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.4
18
+ ```
19
+
20
+ Then read §1 — it is the only thing in this release that can affect you, and `pnpm update` does
21
+ **not** deliver it, because the affected file lives in your repository, not in the package.
22
+
23
+ ---
24
+
25
+ ## 1. Recommended: tighten the test-database drop guard in your own `tests/`
26
+
27
+ **Why this reaches you at all.** `tests/db-lifecycle.reporter.ts` is not part of the npm package
28
+ (`package.json` → `files` does not include `tests/`) and not part of the vendor-mode file set. It is
29
+ a **template** file: `nest-server-starter` ships it, and every project ever generated from the
30
+ starter holds a private copy that no update channel ever touches. So this guide is the only way the
31
+ fix reaches an existing project.
32
+
33
+ **Were you affected?** Only if all three are true:
34
+
35
+ 1. Your project's slug contains `ci`, `test`, `e2e` or `acctest` **as a substring** — including
36
+ inside an ordinary word: `ci` hides in so**ci**al, spe**ci**al, finan**ci**al, invoi**ci**ng,
37
+ pri**ci**ng, muni**ci**pal; `test` hides in la**test**, con**test**, **test**imonials.
38
+ 2. You use `lt dev up`, which exports `MONGODB_URI` pointing at `<slug>-local` — your **development**
39
+ database.
40
+ 3. You started the e2e suite from that same shell.
41
+
42
+ Then `tests/global-setup.ts` dropped that database. The guard meant to prevent exactly this accepted
43
+ it, because it matched the marker anywhere in the name rather than as a delimited segment.
44
+
45
+ ### The fix
46
+
47
+ ```diff
48
+ - export const SAFE_TEST_DB_PATTERN = /(e2e|ci|test|acctest)/i;
49
+ + export const SAFE_TEST_DB_PATTERN = /(^|[-_])(e2e|ci|test|acctest)([-_]|$)/i;
50
+ ```
51
+
52
+ The marker must now be a whole segment, delimited by `-`, `_`, or a string boundary.
53
+
54
+ ### Check your own database names before applying it
55
+
56
+ The change is a strict narrowing — every name the new pattern accepts, the old one accepted too, so
57
+ it cannot start dropping something it previously spared. The risk runs the other way: if your **base**
58
+ test database name does not carry the marker as a delimited segment (`shope2e`, `app-e2edb`,
59
+ `projecttest`), the guard now refuses it and **cleanup silently stops collecting** — the databases
60
+ accumulate with nothing removing them.
61
+
62
+ ```bash
63
+ # What does your suite actually name its databases?
64
+ grep -n "mongodb://" src/config.env.ts
65
+ ```
66
+
67
+ `myproject-e2e` and `myproject_ci` are fine. `myproje2e` is not — rename the database rather than
68
+ loosening the pattern.
69
+
70
+ Since 11.36.4 both sweep loops **count and log** the stale databases the guard refused, so this
71
+ condition announces itself instead of leaking quietly:
72
+
73
+ ```
74
+ Startup sweep: 3 stale database(s) skipped by the safety guard —
75
+ "shope2e" needs a delimited e2e/ci/test/acctest segment for cleanup to work.
76
+ ```
77
+
78
+ ### Two further hardening steps, worth copying
79
+
80
+ Both live in the same file and are optional, but they close the residual cases the pattern alone
81
+ cannot:
82
+
83
+ **A second guard for the one drop site that has no other condition.** The startup sweep and the
84
+ post-run collection both additionally require `isStaleTestDb()` — the name must belong to your own
85
+ base. The externally-supplied-`MONGODB_URI` branch has no such backstop, and a project slug may
86
+ legitimately carry a marker as a *whole word*: `ci` is the ordinary German abbreviation for
87
+ *Corporate Identity*, `test` a product noun for an exam. `ci-portal-local` passes the segment rule.
88
+
89
+ ```typescript
90
+ export const NON_DISPOSABLE_DB_PATTERN
91
+ = /(^|[-_])(local|dev|develop|development|prod|production|staging|stage|live)([-_]|$)/i;
92
+
93
+ export function isDroppableTestDb(name: string): boolean {
94
+ return SAFE_TEST_DB_PATTERN.test(name) && !NON_DISPOSABLE_DB_PATTERN.test(name);
95
+ }
96
+ ```
97
+
98
+ Then use `isDroppableTestDb(db.databaseName)` in place of `SAFE_TEST_DB_PATTERN.test(...)` in
99
+ `tests/global-setup.ts` — **only there**. Applying it to the sweep paths could only turn a collected
100
+ database into a leaked one.
101
+
102
+ **Refuse a URI that names no database.** `mongodb://127.0.0.1` makes the driver fall back to its
103
+ default database, `test` — a name the guard accepts, so a truncated `MONGODB_URI` dropped the
104
+ server's `test` database instead of failing. Note that this needs the `splitMongoUri()` fix too: the
105
+ previous greedy form reported the **host** as the database name for such a URI, so the check could
106
+ never fire.
107
+
108
+ ```typescript
109
+ const match = uri.match(/^(mongodb(?:\+srv)?:\/\/[^/?]*)(?:\/([^/?]*))?(\?.*)?$/i);
110
+ ```
111
+
112
+ This also fixes a second case the greedy form got wrong: an option value containing a slash
113
+ (`?tlsCAFile=/etc/ssl/ca.pem`) previously reported `ca.pem` as the database name.
114
+
115
+ ### Pin it
116
+
117
+ The framework's own spec is `tests/unit/db-lifecycle-guard.spec.ts` — copy it alongside the fix. Its
118
+ refusal list *is* the specification, and reverting the anchoring turns 13 cases red.
119
+
120
+ ---
121
+
122
+ ## Compatibility Notes
123
+
124
+ | Pattern | Status |
125
+ |---------|--------|
126
+ | npm-mode projects | Unaffected by the package update; apply §1 to your own `tests/` |
127
+ | Vendor-mode projects | Same — `tests/` is outside the vendor file set |
128
+ | CI pipelines that pin `MONGODB_URI` | Unaffected, unless the pinned URI names no database or carries an environment suffix. A CI database called `myproject-ci` is fine; one called `myproject-ci-staging` is now refused |
129
+ | Projects not generated from `nest-server-starter` | Nothing to do |
130
+
131
+ ---
132
+
133
+ ## Troubleshooting
134
+
135
+ **`Refusing to dropDatabase("…"): not a recognized disposable test database`**
136
+ Your `MONGODB_URI` points at something the guard will not delete. That is the guard working. Check
137
+ whether the URI is aimed at your development database — an `lt dev` shell exports it that way — and
138
+ point it at a disposable test database instead. Do not loosen the pattern to get past this.
139
+
140
+ **`Refusing to use MONGODB_URI="…": it names no database`**
141
+ The URI has no path (`mongodb://host` rather than `mongodb://host/mydb`). Add the database name.
142
+
143
+ **`N stale database(s) skipped by the safety guard`**
144
+ Your base database name does not carry a delimited marker, so cleanup cannot collect its leftovers.
145
+ Rename the database (see §1).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.36.2",
3
+ "version": "11.36.4",
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",
@@ -181,6 +181,31 @@ export class IamController extends CoreBetterAuthController {
181
181
  }
182
182
  ```
183
183
 
184
+ ### Session cookie vs. body token — do not conflate them
185
+
186
+ If you override a sign-in / sign-up handler, or `processCookies()` itself, know that these two
187
+ carry **different values** whenever `cookies.exposeTokenInBody` is on together with the JWT plugin:
188
+
189
+ | Carrier | Value |
190
+ | ------------------------------------ | ---------------------------------------- |
191
+ | `Set-Cookie: <prefix>.session_token` | the **opaque** Better-Auth session token |
192
+ | response body `token` | a **JWT** |
193
+
194
+ Better-Auth resolves a session by the opaque token it stored, so a JWT in the cookie authenticates
195
+ nothing — sign-in succeeds and every following request is anonymous. That shipped once (11.36.3).
196
+
197
+ In an override, derive the cookie value with `this.sessionTokenForCookie(response)` rather than
198
+ reading `response.session?.token` yourself: `session.token` is optional on the `hasSession()` type
199
+ guard, and `api.signInEmail()` returns the token at the top level with no `session` object at all,
200
+ so the obvious expression yields `undefined` in exactly the case that matters.
201
+
202
+ You do not have to get this right for it to be safe: `setSessionCookies()` refuses a JWT-shaped
203
+ value and logs an error, so a mistake here costs you the cookie, not the session.
204
+
205
+ Overriding `processAuthResult()` on `BetterAuthCookieHelper`? Keep the fifth parameter
206
+ (`sessionToken`). TypeScript accepts an override with fewer parameters, so dropping it compiles
207
+ cleanly and silently disables the distinction above.
208
+
184
209
  ### Important Notes
185
210
 
186
211
  - Always extend `CoreBetterAuthController`
@@ -179,6 +179,12 @@ export class ServerModule {}
179
179
  > **Never set `exposeTokenInBody: true` in production** — the framework throws at
180
180
  > startup if this is detected in `production` or `staging` environments (XSS-risk guard).
181
181
  >
182
+ > **In this hybrid mode the body token and the session cookie carry DIFFERENT values** whenever
183
+ > the JWT plugin is also active: the body gets a JWT, the cookie keeps the opaque Better-Auth
184
+ > session token. If you override a sign-in handler or `processCookies()`, read
185
+ > [CUSTOMIZATION.md → _Session cookie vs. body token_](./CUSTOMIZATION.md#session-cookie-vs-body-token--do-not-conflate-them)
186
+ > before you derive the cookie value yourself — conflating the two is what caused 11.36.3.
187
+ >
182
188
  > To keep the old behavior (cookies off, tokens in body everywhere), set `cookies: false`.
183
189
 
184
190
  #### Zero-Config (Default):
@@ -262,10 +262,10 @@ Read the security section below for production deployments.
262
262
 
263
263
  **Global server-level settings that affect BetterAuth behavior (since v11.25.0):**
264
264
 
265
- | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
- | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
- | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
265
+ | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
+ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns a token in the response body — a JWT when the JWT plugin is active, which is NOT the value the session cookie carries (see CUSTOMIZATION.md) (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
+ | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
269
269
 
270
270
  **For Development:** The defaults (`http://localhost:3000`, `/iam`) are correct.
271
271
 
@@ -119,6 +119,36 @@ export function hasSession<T>(
119
119
  );
120
120
  }
121
121
 
122
+ /**
123
+ * Whether a Better-Auth response carries a usable top-level `token`.
124
+ *
125
+ * Sibling of {@link hasSession} / {@link hasUser}, and the reason it exists: `api.signInEmail()`
126
+ * and `api.signUpEmail()` return the session token at the TOP level and no `session` object at all,
127
+ * so `response.session?.token` is `undefined` on exactly the routes that matter most.
128
+ */
129
+ export function hasToken<T>(response: T): response is T & { token: string } {
130
+ return typeof (response as { token?: unknown })?.token === 'string' && (response as { token: string }).token !== '';
131
+ }
132
+
133
+ /**
134
+ * The OPAQUE Better-Auth session token carried by an outgoing auth response, if any.
135
+ *
136
+ * A PRECEDENCE CHAIN, not a ternary on {@link hasSession}, and the difference is load-bearing:
137
+ * `hasSession()` narrows to `session: { …; token?: string }`, so `token` is OPTIONAL on the guard
138
+ * itself. A response carrying a `session` object WITHOUT a token inside it satisfies the guard,
139
+ * yields `undefined` and skips the top-level fallback. `api.signInEmail()` is the mirror case:
140
+ * top-level token, no `session` object at all.
141
+ *
142
+ * Neither shape is reachable in better-auth 1.6.26 today; both are one minor bump away, and the
143
+ * failure is silent — which is why every caller that needs the stored session value shares this
144
+ * one chain rather than re-deriving it. Callers that need the value for a COOKIE must additionally
145
+ * refuse a JWT; see `CoreBetterAuthController.sessionTokenForCookie()`.
146
+ */
147
+ export function sessionTokenFromResponse(response: unknown): string | undefined {
148
+ const fromSession = hasSession(response) && hasToken(response.session) ? response.session.token : undefined;
149
+ return fromSession ?? (hasToken(response) ? response.token : undefined);
150
+ }
151
+
122
152
  /**
123
153
  * Type guard to check if response has user
124
154
  * Preserves the original type while asserting user is defined
@@ -3,8 +3,20 @@ import { Response } from 'express';
3
3
 
4
4
  import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
5
5
  import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper';
6
+ import { isJwtShaped } from './core-better-auth-token.helper';
6
7
  import { signCookieValue } from './core-better-auth-web.helper';
7
8
 
9
+ /**
10
+ * Last-resort logger for the JWT refusal below.
11
+ *
12
+ * `BetterAuthCookieHelperConfig.logger` is optional and `createCookieHelper()` takes it as an
13
+ * optional third argument, so a consumer constructing this class directly gets none. The refusal
14
+ * is the one message here that must never be silent: without it the symptom is "no cookie, HTTP
15
+ * 200, no explanation" — the same diagnostic blind spot that let the defect it guards against ship
16
+ * in the first place.
17
+ */
18
+ const fallbackLogger = new Logger('BetterAuthCookieHelper');
19
+
8
20
  /**
9
21
  * Standard cookie names used by Better-Auth and nest-server.
10
22
  *
@@ -193,6 +205,24 @@ export class BetterAuthCookieHelper {
193
205
  * @param _sessionId - Deprecated, kept for API compatibility but no longer used
194
206
  */
195
207
  setSessionCookies(res: Response, sessionToken: string, _sessionId?: string): void {
208
+ // Structural invariant, independent of whichever call site got here: Better-Auth resolves a
209
+ // session by the OPAQUE token it stored, so a JWT in this cookie authenticates nothing. That is
210
+ // not hypothetical — it shipped: with `cookies.exposeTokenInBody` the body's JWT was written
211
+ // here, sign-in appeared to work and every following request was anonymous.
212
+ //
213
+ // Enforced here rather than at the call sites because a call site can forget, and this one
214
+ // cannot: whoever adds the next authentication route inherits the guard for free.
215
+ if (isJwtShaped(sessionToken)) {
216
+ // NOT `this.config.logger?.error(...)`: `logger` is optional, and a silent refusal leaves the
217
+ // caller with no cookie, a 200 response and nothing to go on.
218
+ (this.config.logger ?? fallbackLogger).error(
219
+ 'Refusing to write a JWT into the session cookie — Better-Auth resolves a session by the ' +
220
+ 'opaque token it stored, so this would authenticate nothing. Pass the session token, ' +
221
+ 'not the value destined for the response body.',
222
+ );
223
+ return;
224
+ }
225
+
196
226
  const cookieOptions = this.getDefaultCookieOptions();
197
227
 
198
228
  // Sign the session token for Better-Auth
@@ -323,6 +353,13 @@ export class BetterAuthCookieHelper {
323
353
  * @param result - The result object to process (modified in place)
324
354
  * @param cookiesEnabled - Whether cookie handling is enabled (from config)
325
355
  * @param exposeTokenInBody - Whether to keep the token in the response body (default: false)
356
+ * @param sessionToken - The OPAQUE Better-Auth session token, for the COOKIE. Pass it whenever
357
+ * the body's `token` may already be a JWT — i.e. `exposeTokenInBody` together with the JWT
358
+ * plugin. Better-Auth resolves a session by the opaque value it stored, so a JWT in the cookie
359
+ * authenticates nothing: sign-in appears to succeed and every following request is anonymous.
360
+ * Falls back to `result.token`, which is correct only in cookies-only mode, where the two
361
+ * values are the same thing. `setSessionCookies()` refuses a JWT-shaped value regardless, so
362
+ * omitting this fails loudly rather than silently.
326
363
  * @returns The modified result (same reference as input)
327
364
  */
328
365
  processAuthResult<T extends CookieProcessingResult>(
@@ -330,19 +367,24 @@ export class BetterAuthCookieHelper {
330
367
  result: T,
331
368
  cookiesEnabled: boolean,
332
369
  exposeTokenInBody: boolean = false,
370
+ sessionToken?: string,
333
371
  ): T {
334
372
  if (!cookiesEnabled) {
335
373
  return result;
336
374
  }
337
375
 
338
- if (result.token) {
339
- // Set cookies
340
- this.setSessionCookies(res, result.token);
376
+ // The cookie and the body carry DIFFERENT values whenever both delivery modes are on at once —
377
+ // see the `sessionToken` note on the signature above. `result.token` is the fallback for
378
+ // cookies-only mode, where the two are the same thing anyway.
379
+ const cookieToken = sessionToken ?? result.token;
341
380
 
342
- // Remove token from response body unless exposeTokenInBody is enabled
343
- if (!exposeTokenInBody) {
344
- delete result.token;
345
- }
381
+ if (cookieToken) {
382
+ this.setSessionCookies(res, cookieToken);
383
+ }
384
+
385
+ // Remove token from response body unless exposeTokenInBody is enabled
386
+ if (result.token && !exposeTokenInBody) {
387
+ delete result.token;
346
388
  }
347
389
 
348
390
  return result;
@@ -156,6 +156,22 @@ export function isJwt(token: string): boolean {
156
156
  return result.type === TokenType.LEGACY_JWT || result.type === TokenType.BETTER_AUTH_JWT;
157
157
  }
158
158
 
159
+ /**
160
+ * Whether a value has the SHAPE of a JWT — three dot-separated segments starting with `eyJ`.
161
+ *
162
+ * Deliberately structural and deliberately not `startsWith('eyJ')` alone: a Better-Auth session
163
+ * token is `generateId(32)` over `a-zA-Z0-9-_`, so roughly one in 262 144 of them begins with those
164
+ * three characters by chance. That is rare enough to reach production as a ghost and frequent
165
+ * enough to happen. Requiring the dots removes the false positive entirely, because an opaque
166
+ * session token can never contain one.
167
+ *
168
+ * Unlike {@link analyzeToken} this makes no claim about the token's CONTENTS or validity — it
169
+ * answers "could this be a JWT?", which is the question a guard on a session cookie needs.
170
+ */
171
+ export function isJwtShaped(value: unknown): value is string {
172
+ return typeof value === 'string' && value.startsWith('eyJ') && value.split('.').length === 3;
173
+ }
174
+
159
175
  /**
160
176
  * Checks if a token is a Legacy JWT (Passport/JWT strategy).
161
177
  *
@@ -34,11 +34,17 @@ import type { ICookiesConfig } from '../../common/interfaces/server-options.inte
34
34
  import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
35
35
  import { ConfigService } from '../../common/services/config.service';
36
36
  import { ErrorCode } from '../error-code/error-codes';
37
- import { BetterAuthSignInResponse, hasSession, hasUser, requires2FA } from './better-auth.types';
37
+ import {
38
+ BetterAuthSignInResponse,
39
+ hasSession,
40
+ hasUser,
41
+ requires2FA,
42
+ sessionTokenFromResponse,
43
+ } from './better-auth.types';
38
44
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
39
45
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
40
46
  import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
41
- import { isSessionToken } from './core-better-auth-token.helper';
47
+ import { isJwtShaped, isSessionToken } from './core-better-auth-token.helper';
42
48
  import { BetterAuthSessionUser, CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
43
49
  import { convertExpressHeaders, sendWebResponse, toWebRequest } from './core-better-auth-web.helper';
44
50
  import { CoreBetterAuthService } from './core-better-auth.service';
@@ -469,8 +475,15 @@ export class CoreBetterAuthController {
469
475
  // Get token: JWT accessToken > top-level token > session.token
470
476
  const rawToken =
471
477
  responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
478
+ // `rawToken` prefers the top-level token; `sessionTokenForCookie()` prefers `session.token`.
479
+ // The inversion is deliberate: the body wants whatever Better-Auth called the token, the
480
+ // cookie wants the stored session value specifically.
472
481
  const token = await this.resolveJwtToken(rawToken);
473
482
 
483
+ // Kept separately from `token`: `resolveJwtToken` turns the session token into a JWT when
484
+ // the body is meant to carry one, and the cookie must not get that JWT.
485
+ const sessionToken = this.sessionTokenForCookie(response);
486
+
474
487
  const result: CoreBetterAuthResponse = {
475
488
  requiresTwoFactor: false,
476
489
  session: hasSession(response) ? this.mapSession(response.session) : undefined,
@@ -479,7 +492,7 @@ export class CoreBetterAuthController {
479
492
  user: mappedUser ? this.mapUser(response.user, mappedUser) : undefined,
480
493
  };
481
494
 
482
- return this.processCookies(res, result);
495
+ return this.processCookies(res, result, sessionToken);
483
496
  }
484
497
 
485
498
  throw new UnauthorizedException(ErrorCode.INVALID_CREDENTIALS);
@@ -583,7 +596,11 @@ export class CoreBetterAuthController {
583
596
  // The user must verify their email before they can use any session
584
597
  if (this.emailVerificationService?.isEnabled()) {
585
598
  // Revoke the Better-Auth session server-side so the token is invalidated
586
- const sessionToken = hasSession(response) ? response.session.token : undefined;
599
+ // Shared chain, NOT `hasSession(response) ? response.session.token : undefined`: `token` is
600
+ // optional on the guard, so that expression returns `undefined` for a response shape that
601
+ // still carries a revocable token — and the endpoint would then report a revoked session
602
+ // while a live one remained in the database.
603
+ const sessionToken = sessionTokenFromResponse(response);
587
604
  if (sessionToken) {
588
605
  await this.betterAuthService.revokeSession(sessionToken);
589
606
  }
@@ -607,7 +624,9 @@ export class CoreBetterAuthController {
607
624
  user: mappedUser ? this.mapUser(response.user, mappedUser) : undefined,
608
625
  };
609
626
 
610
- return this.processCookies(res, result);
627
+ // Same as sign-in: the cookie gets the opaque session token, the body keeps whatever
628
+ // `resolveJwtToken` produced.
629
+ return this.processCookies(res, result, this.sessionTokenForCookie(response));
611
630
  }
612
631
 
613
632
  throw new BadRequestException(ErrorCode.SIGNUP_FAILED);
@@ -867,6 +886,27 @@ export class CoreBetterAuthController {
867
886
  };
868
887
  }
869
888
 
889
+ /**
890
+ * The value that belongs in the SESSION COOKIE — never the value destined for the response body.
891
+ *
892
+ * Not to be confused with {@link extractSessionToken}, which reads a token out of an INCOMING
893
+ * request. This one derives it from an OUTGOING Better-Auth response; the two run in opposite
894
+ * directions, which is why they do not share a name.
895
+ *
896
+ * The precedence chain itself lives in {@link sessionTokenFromResponse}, shared with the
897
+ * session-revocation paths so the two cannot drift. What this method adds is the COOKIE-specific
898
+ * half: a JWT-shaped result is refused outright, because Better-Auth resolves a session by the
899
+ * opaque value it stored and a JWT in the cookie would authenticate nothing.
900
+ *
901
+ * Override to source the cookie token differently. `setSessionCookies()` refuses a JWT-shaped
902
+ * value regardless, so an override cannot reintroduce the defect either.
903
+ */
904
+ protected sessionTokenForCookie(response: unknown): string | undefined {
905
+ const resolved = sessionTokenFromResponse(response);
906
+ // A JWT here means somebody handed over the body's value; refuse rather than pass it on.
907
+ return isJwtShaped(resolved) ? undefined : resolved;
908
+ }
909
+
870
910
  /**
871
911
  * Process cookies for response
872
912
  *
@@ -903,8 +943,14 @@ export class CoreBetterAuthController {
903
943
  return result;
904
944
  }
905
945
 
906
- // Otherwise, use the cookie helper's standard processing
907
- return this.cookieHelper.processAuthResult(res, result, cookiesEnabled, exposeTokenInBody);
946
+ // Otherwise the helper's standard processing. Read this carefully before deleting anything:
947
+ // a TRUTHY `sessionToken` reaches here only when cookies are disabled, in which case
948
+ // `processAuthResult` returns immediately anyway. But `sessionTokenForCookie()` legitimately
949
+ // returns `undefined` — no token in the response, or a JWT it refused — and THAT case falls
950
+ // through with cookies ENABLED, where `cookieToken` falls back to `result.token`, i.e. the
951
+ // body's JWT. What makes that safe is not unreachability; it is `setSessionCookies()` refusing
952
+ // a JWT-shaped value. Remove the refusal and this line reinstates the defect.
953
+ return this.cookieHelper.processAuthResult(res, result, cookiesEnabled, exposeTokenInBody, sessionToken);
908
954
  }
909
955
 
910
956
  /**