@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.
- package/.claude/rules/better-auth.md +54 -0
- package/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/testing.md +88 -4
- package/CLAUDE.md +1 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/better-auth/better-auth.types.d.ts +4 -0
- package/dist/core/modules/better-auth/better-auth.types.js +9 -0
- package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +16 -6
- package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-token.helper.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth-token.helper.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth-token.helper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +9 -4
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +8 -0
- package/migration-guides/11.36.2-to-11.36.3.md +131 -0
- package/migration-guides/11.36.3-to-11.36.4.md +145 -0
- package/package.json +1 -1
- package/src/core/modules/better-auth/CUSTOMIZATION.md +25 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +6 -0
- package/src/core/modules/better-auth/README.md +4 -4
- package/src/core/modules/better-auth/better-auth.types.ts +30 -0
- package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +49 -7
- package/src/core/modules/better-auth/core-better-auth-token.helper.ts +16 -0
- package/src/core/modules/better-auth/core-better-auth.controller.ts +53 -7
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +6 -1
- 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.
|
|
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
|
|
266
|
-
| ----------------------------------------------------------------------------- |
|
|
267
|
-
| `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
907
|
-
|
|
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
|
/**
|