@lenne.tech/nest-server 11.36.2 → 11.36.3
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 +23 -3
- 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/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 |
|
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.3",
|
|
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
|
/**
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hasSession,
|
|
14
14
|
hasUser,
|
|
15
15
|
requires2FA,
|
|
16
|
+
sessionTokenFromResponse,
|
|
16
17
|
} from './better-auth.types';
|
|
17
18
|
import { CoreBetterAuthAuthModel } from './core-better-auth-auth.model';
|
|
18
19
|
import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
|
|
@@ -490,7 +491,11 @@ export class CoreBetterAuthResolver {
|
|
|
490
491
|
// If email verification is enabled, revoke the session and don't return session data
|
|
491
492
|
// The user must verify their email before they can use any session
|
|
492
493
|
if (this.emailVerificationService?.isEnabled()) {
|
|
493
|
-
|
|
494
|
+
// Shared chain, NOT `hasSession(response) ? response.session.token : undefined`: `token` is
|
|
495
|
+
// optional on the guard, so that expression returns `undefined` for a response shape that
|
|
496
|
+
// still carries a revocable token — and the endpoint would then report a revoked session
|
|
497
|
+
// while a live one remained in the database.
|
|
498
|
+
const sessionToken = sessionTokenFromResponse(response);
|
|
494
499
|
if (sessionToken) {
|
|
495
500
|
await this.betterAuthService.revokeSession(sessionToken);
|
|
496
501
|
}
|
|
@@ -11,6 +11,7 @@ import { ConfigService } from '../../common/services/config.service';
|
|
|
11
11
|
import { ErrorCode } from '../error-code/error-codes';
|
|
12
12
|
import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
|
|
13
13
|
import { BetterAuthInstance } from './better-auth.config';
|
|
14
|
+
import { isJwtShaped } from './core-better-auth-token.helper';
|
|
14
15
|
import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
|
|
15
16
|
import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
|
|
16
17
|
import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
|
|
@@ -359,8 +360,10 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
359
360
|
return token;
|
|
360
361
|
}
|
|
361
362
|
|
|
362
|
-
// Already a JWT (three base64url segments separated by dots)
|
|
363
|
-
|
|
363
|
+
// Already a JWT (three base64url segments separated by dots). Shares ONE definition with the
|
|
364
|
+
// session-cookie guard — two copies of this predicate would let the conversion path and the
|
|
365
|
+
// cookie path disagree about the same token, silently.
|
|
366
|
+
if (isJwtShaped(token)) {
|
|
364
367
|
return token;
|
|
365
368
|
}
|
|
366
369
|
|