@lenne.tech/nest-server 11.42.15 → 11.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/better-auth.md +22 -0
- package/.claude/rules/configurable-features.md +4 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +6 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
- package/dist/core/modules/ai/index.d.ts +1 -0
- package/dist/core/modules/ai/index.js +1 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
- package/dist/core/modules/better-auth/better-auth.config.js +29 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +30 -1
- package/docs/security-overrides.md +3 -2
- package/migration-guides/11.42.15-to-11.42.16.md +114 -0
- package/migration-guides/11.42.x-to-11.43.x.md +195 -0
- package/package.json +7 -7
- package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
- package/src/core/common/helpers/swagger.helper.ts +4 -4
- package/src/core/common/interfaces/server-options.interface.ts +59 -9
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
- package/src/core/modules/ai/README.md +98 -13
- package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
- package/src/core/modules/ai/core-ai.module.ts +14 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
- package/src/core/modules/ai/index.ts +1 -0
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
- package/src/core/modules/better-auth/better-auth.config.ts +49 -5
- package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
- package/src/core.module.ts +13 -1
- package/src/index.ts +1 -0
|
@@ -107,6 +107,7 @@ JWT-based authentication for existing projects:
|
|
|
107
107
|
| **Security Check Interceptor** | Calls `securityCheck()` + removes secret fields |
|
|
108
108
|
| **Response Filter Interceptor** | Enforces `@Restricted()` field-level access |
|
|
109
109
|
| **Translation Interceptor** | Applies `_translations` based on `Accept-Language` |
|
|
110
|
+
| **CastError Filter** | `MongooseCastErrorFilter` (`APP_FILTER`, 11.42.16): a malformed id from a route parameter answers 404, one from the query string or an update/filter body 400, instead of an unhandled 500 (a create body raises `ValidationError` and stays a 500); a cast failure on a value the request does not contain stays a 500. See "Malformed ids: 404 / 400, not 500" below |
|
|
110
111
|
| **Secret Fields Removal** | Configurable fallback removal of password, tokens, etc. |
|
|
111
112
|
| **RequestContext** | `AsyncLocalStorage`-based context for current user in Mongoose hooks. `fromRequest` (11.42.0) marks a context created for a CLIENT operation (HTTP middleware, GraphQL WebSocket), so service-level checks such as `assertTenantRole()` tell an anonymous request apart from system work |
|
|
112
113
|
| **Query Complexity** | GraphQL query complexity analysis to prevent DoS |
|
|
@@ -178,7 +179,7 @@ JWT-based authentication for existing projects:
|
|
|
178
179
|
| **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
|
|
179
180
|
| **REST Endpoints** | `POST /ai/prompt`, `POST /ai/stream` (SSE); `GET/POST/PUT/DELETE /ai/connections*`, `/ai/connections/available`, `/ai/connections/select`, `/ai/connections/preferences*`, `/ai/conversations*`, `/ai/interactions*`, `/ai/budget-limits*`, `GET /ai/usage` |
|
|
180
181
|
| **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
|
|
181
|
-
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth);
|
|
182
|
+
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
|
|
182
183
|
| **Permission Model** | `S_USER` for prompt/conversations/available/usage; `ADMIN` for connections/preferences/interactions/budgets; encrypted API keys never leave the server (`securityCheck` + `secretFields`) |
|
|
183
184
|
|
|
184
185
|
### Email & Templates
|
|
@@ -1215,6 +1216,33 @@ throw accessDeniedException(currentUser);
|
|
|
1215
1216
|
See `src/core/common/exceptions/access-denied.exception.ts` and
|
|
1216
1217
|
`migration-guides/11.27.7-to-11.28.0.md`.
|
|
1217
1218
|
|
|
1219
|
+
### Malformed ids: 404 / 400, not 500 (v11.42.16+)
|
|
1220
|
+
|
|
1221
|
+
A malformed id reaches Mongoose, which throws `mongoose.Error.CastError`. That is not an
|
|
1222
|
+
`HttpException`, so it escapes `HttpExceptionLogFilter` (`@Catch(HttpException)`), and Nest used to
|
|
1223
|
+
answer **500** for what is simply "this resource does not exist". `CoreModule` registers
|
|
1224
|
+
`MongooseCastErrorFilter` as `APP_FILTER`; it decides by where the failing value came from:
|
|
1225
|
+
|
|
1226
|
+
| Value of the failed cast | REST | GraphQL |
|
|
1227
|
+
|--------------------------|------|---------|
|
|
1228
|
+
| a route parameter (`GET /users/not-an-id`) / a top-level argument (`getUser(id: …)`) | **404** `ErrorCode.RESOURCE_NOT_FOUND` | same |
|
|
1229
|
+
| the query string or the body / nested in an input object | **400** `ErrorCode.INVALID_FIELD_FORMAT [<path>]` | same |
|
|
1230
|
+
| not in the request — a broken stored id, a value computed by server code | **500**, Nest's own body and log line | unchanged (Nest rethrows) |
|
|
1231
|
+
|
|
1232
|
+
The third row is deliberate: that cast failure is a data or code defect, and answering it as a client
|
|
1233
|
+
error would hide it from monitoring. The Mongoose message never reaches the client — it names the
|
|
1234
|
+
model. Opt out with `security.mongooseCastErrorFilter: false`.
|
|
1235
|
+
|
|
1236
|
+
Mongoose throws `CastError` only while casting a **query or an update** — `findById`, filters,
|
|
1237
|
+
`findByIdAndUpdate` / `updateOne` (the path `CrudService.update()` takes). Two neighbours are not
|
|
1238
|
+
covered and stay a 500:
|
|
1239
|
+
|
|
1240
|
+
- a malformed id in a document being **created or saved** (`save()`, `Model.create()`, `insertMany()`
|
|
1241
|
+
— the path `CrudService.create()` takes) arrives as a `ValidationError` wrapping the CastError;
|
|
1242
|
+
validate reference ids in the input (`validator: () => [IsMongoId()]` on the `@UnifiedField`);
|
|
1243
|
+
- a `BSONError` from `new Types.ObjectId(bad)` in service code carries no value to classify; validate
|
|
1244
|
+
such ids in the service, as `CoreFileService.isMalformedFileId()` does.
|
|
1245
|
+
|
|
1218
1246
|
### Depth-Based Optimization (v11.23.0+)
|
|
1219
1247
|
|
|
1220
1248
|
When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
|
|
@@ -1643,6 +1671,7 @@ All security features are configured in `config.env.ts` under the `security` key
|
|
|
1643
1671
|
| `security.checkSecurityInterceptor` | `boolean \| object` | `true` | Enable securityCheck() calls |
|
|
1644
1672
|
| `security.mapAndValidatePipe` | `boolean \| object` | `true` | Enable input validation |
|
|
1645
1673
|
| `security.mapAndValidatePipe.nonWhitelistedFields` | `'strip' \| 'error' \| false` | `'strip'` | Whitelist behavior |
|
|
1674
|
+
| `security.mongooseCastErrorFilter` | `boolean` | `true` | Malformed request ids answer 404 / 400 instead of 500 |
|
|
1646
1675
|
|
|
1647
1676
|
### Safety Net — Mongoose Plugins
|
|
1648
1677
|
|
|
@@ -64,13 +64,14 @@ overrides:
|
|
|
64
64
|
'multer@>=2.0.0 <2.4.0': '2.4.0'
|
|
65
65
|
|
|
66
66
|
# @nestjs/graphql 13.4.5 exact-pins @graphql-tools/utils@12.0.0 — GHSA-7mx3-vvmw-hjmv (high, patched >=12.0.1).
|
|
67
|
-
# Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back
|
|
67
|
+
# Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back —
|
|
68
|
+
# @graphql-tools/schema 10.1.5 requests ^12.1.0, so a lower target leaves a second copy in the tree.
|
|
68
69
|
# 12.0.2+ requires Node >=22.15 via @whatwg-node/promise-helpers@2 — the floor this framework declares
|
|
69
70
|
# since 11.42.10. On an older Node, take 12.0.1 instead.
|
|
70
71
|
# Does NOT reach a utils 11.x pulled in by an older @graphql-tools/schema 10.0.x — refresh that one instead:
|
|
71
72
|
# pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge
|
|
72
73
|
# Remove once @nestjs/graphql pins >=12.0.1 in the major you use.
|
|
73
|
-
'@graphql-tools/utils@>=12.0.0 <12.0
|
|
74
|
+
'@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0'
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
### Retired: `@hono/node-server` (removed 2026-08-22, nest-server 11.36.1)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Migration Guide: 11.42.15 → 11.42.16
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| **Bugfixes** | A malformed id in a route parameter answers **404** instead of **500**; one in the query string or an update body **400** (§1) |
|
|
8
|
+
| **New Features** | `MongooseCastErrorFilter`, registered automatically; opt-out `security.mongooseCastErrorFilter: false` (§1) |
|
|
9
|
+
| **Framework API** | New exports: `MongooseCastErrorFilter`, `classifyHttpCastError()`, `classifyGraphQlCastError()`, `castErrorCandidates()`, `containsCastValue()`, type `CastErrorOrigin`. No changed signatures |
|
|
10
|
+
| **Dependencies** | Development tooling only: `vitest` / `@vitest/coverage-v8` 4.1.11 → 4.1.12 (security, §2), `oxfmt` 0.72.0 → 0.73.0; override `@graphql-tools/utils` → 12.1.0 (§2). Runtime dependencies and peer ranges unchanged |
|
|
11
|
+
| **Breaking Changes** | None. Status codes change from 500 to 404 / 400 for requests that were already failing (§1) |
|
|
12
|
+
| **Migration Effort** | None for most projects. Check tests or clients that expected a 500 for a malformed id (§1) |
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Vendor-mode projects pick it up with the core update. No code or configuration change is required — the filter is
|
|
21
|
+
registered by `CoreModule`, so neither `main.ts` nor test setups built with `Test.createTestingModule()` need a new
|
|
22
|
+
line.
|
|
23
|
+
|
|
24
|
+
## 1. Malformed ids answer 404 / 400 instead of 500
|
|
25
|
+
|
|
26
|
+
A malformed id in a route parameter (`GET /users/not-an-id`, `/ai/connections/abc`) reached Mongoose, which throws
|
|
27
|
+
`mongoose.Error.CastError`. That is not an `HttpException`, so it escaped `HttpExceptionLogFilter`
|
|
28
|
+
(`@Catch(HttpException)`) and Nest answered **500 Internal server error** for what is simply "this resource does not
|
|
29
|
+
exist". In a typical project that affected every `:id` route, including the framework's own `/users/:id` and the AI
|
|
30
|
+
module's routes.
|
|
31
|
+
|
|
32
|
+
`CoreModule` now registers `MongooseCastErrorFilter` as `APP_FILTER`. It decides by where the value that failed to
|
|
33
|
+
cast came from:
|
|
34
|
+
|
|
35
|
+
| Value of the failed cast | REST | GraphQL |
|
|
36
|
+
| ------------------------ | ---- | ------- |
|
|
37
|
+
| a route parameter / a top-level argument (`getUser(id: …)`) | **404** `#LTNS_0400: Resource not found` | same |
|
|
38
|
+
| the query string or an update / filter body / a value nested in an input | **400** `#LTNS_0302: Invalid field format [<path>]` | same |
|
|
39
|
+
| not in the request (a broken stored id, a value computed by server code) | **500**, unchanged — Nest's body, logged as an error | unchanged |
|
|
40
|
+
|
|
41
|
+
The third row is deliberate: a cast failure on a value the request does not contain is a data or code defect, and
|
|
42
|
+
answering it as a client error would hide it from monitoring. The Mongoose message is never sent to the client — it
|
|
43
|
+
names the model. REST bodies have the same shape as every other error `HttpExceptionLogFilter` answers.
|
|
44
|
+
|
|
45
|
+
**What to check:**
|
|
46
|
+
|
|
47
|
+
- **A test or client expected 500 for a malformed id:** it now gets 404 (route parameter) or 400 (query / body).
|
|
48
|
+
A well-formed id that matches nothing keeps answering 404 as before, so 404 now means the same for both.
|
|
49
|
+
- **A project filter already handled `CastError`:** a catch-all filter registered with `app.useGlobalFilters()` is
|
|
50
|
+
consulted before this one and keeps winning. One registered as `APP_FILTER` in the project's root module is
|
|
51
|
+
consulted after it — set `security.mongooseCastErrorFilter: false` if your own filter must see every `CastError`
|
|
52
|
+
(for example a Sentry filter that reports them).
|
|
53
|
+
- **You want the previous behaviour:** `security.mongooseCastErrorFilter: false` restores the 500.
|
|
54
|
+
|
|
55
|
+
**Not covered — both stay a 500:**
|
|
56
|
+
|
|
57
|
+
- A malformed id in a document being **created** (`save()`, `Model.create()`, `insertMany()` — the path
|
|
58
|
+
`CrudService.create()` takes). Mongoose reports that as a `ValidationError` wrapping the `CastError`. Validate
|
|
59
|
+
reference ids in the input instead:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { IsMongoId } from 'class-validator';
|
|
63
|
+
|
|
64
|
+
@UnifiedField({ isOptional: true, type: () => String, validator: () => [IsMongoId()] })
|
|
65
|
+
owner?: string = undefined;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- A `BSONError` from `new Types.ObjectId(bad)` in service code. It carries no value to classify; check the id
|
|
69
|
+
before converting it, as `CoreFileService.isMalformedFileId()` does.
|
|
70
|
+
|
|
71
|
+
## 2. Development tooling and the `@graphql-tools/utils` override
|
|
72
|
+
|
|
73
|
+
Nothing here reaches the published package's runtime dependencies. Worth mirroring in your own project:
|
|
74
|
+
|
|
75
|
+
- **`vitest` and `@vitest/coverage-v8` 4.1.12** fix two `@vitest/mocker` advisories published on 2026-10-16
|
|
76
|
+
(GHSA-qxg6-89mg-rqv8, GHSA-xp26-3454-mjgp, both medium). Development tooling only. `pnpm audit` may not list them
|
|
77
|
+
yet, so raise both together without waiting for it: `@vitest/coverage-v8` peer-requires the exact vitest version.
|
|
78
|
+
- **`@graphql-tools/utils` override:** if your project carries its own entry for GHSA-7mx3-vvmw-hjmv (overrides do
|
|
79
|
+
not reach consumers, so many do), raise it:
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# pnpm-workspace.yaml
|
|
83
|
+
overrides:
|
|
84
|
+
'@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0' # was: '<12.0.3': '12.0.3' / '<12.0.4': '12.0.4'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The old target still clears the advisory, but `@graphql-tools/schema` 10.1.5 (pulled in by `@apollo/server`)
|
|
88
|
+
requests `^12.1.0`, so a lower target leaves two copies of `@graphql-tools/utils` in the tree.
|
|
89
|
+
See [`docs/security-overrides.md`](../docs/security-overrides.md).
|
|
90
|
+
|
|
91
|
+
## Compatibility Notes
|
|
92
|
+
|
|
93
|
+
| Pattern | Status |
|
|
94
|
+
| ------- | ------ |
|
|
95
|
+
| `main.ts` with `app.useGlobalFilters(new HttpExceptionLogFilter())` | Unchanged; both filters coexist (they catch different classes) |
|
|
96
|
+
| A project catch-all filter via `app.useGlobalFilters()` | Unchanged; it still sees every `CastError` |
|
|
97
|
+
| A project catch-all filter as `APP_FILTER` in the root module | No longer sees `CastError` — opt out if it must (§1) |
|
|
98
|
+
| Tests built with `Test.createTestingModule({ imports: [ServerModule] })` | Get the filter automatically |
|
|
99
|
+
| `security.mongooseCastErrorFilter: false` | Previous behaviour (500) |
|
|
100
|
+
|
|
101
|
+
## Troubleshooting
|
|
102
|
+
|
|
103
|
+
**A malformed id still answers 500**
|
|
104
|
+
Check where it fails. A create path raises `ValidationError` (see "Not covered" above); a `new Types.ObjectId(bad)` in
|
|
105
|
+
service code raises `BSONError`; a value from a header or from stored data is not in the request and stays a 500 on
|
|
106
|
+
purpose. The server log names the cast at debug level (`MongooseCastErrorFilter`) when the filter answered 404 / 400.
|
|
107
|
+
|
|
108
|
+
**A 400 names a field the client did not send**
|
|
109
|
+
The path in brackets is the schema path Mongoose failed to cast. For a filter on `id` it reads `[_id]`.
|
|
110
|
+
|
|
111
|
+
## Module Documentation
|
|
112
|
+
|
|
113
|
+
- [Request lifecycle](../docs/REQUEST-LIFECYCLE.md) — "Malformed ids: 404 / 400, not 500"
|
|
114
|
+
- Implementation: `src/core/common/filters/mongoose-cast-error.filter.ts`
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Migration Guide: 11.42.x → 11.43.x
|
|
2
|
+
|
|
3
|
+
## Why this is a MINOR
|
|
4
|
+
|
|
5
|
+
Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
|
|
6
|
+
live**. Two behaviour changes can turn a call that used to succeed into a refusal: a browser sign-in
|
|
7
|
+
or sign-up from an origin Better-Auth does not trust now answers **403** in every project (§5), and
|
|
8
|
+
with `ai.mcp.oauth` enabled a deployed environment without a signing secret or without `baseUrl`
|
|
9
|
+
now fails the boot instead of signing tokens with the public development key (§1, §4). So this is
|
|
10
|
+
`11.43.0` rather than a patch.
|
|
11
|
+
|
|
12
|
+
**Projects whose frontends sign in from `appUrl` or an origin in `cors.allowedOrigins`, and that do
|
|
13
|
+
not use `ai.mcp.oauth`, see no behaviour change.** Server-side clients (scripts, seeders, SSR
|
|
14
|
+
routes, API tests) are not affected either (§5).
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
| Category | Details |
|
|
19
|
+
| -------------------- | ------- |
|
|
20
|
+
| **Bugfixes** | MCP OAuth endpoints were unreachable in every Nest application (404) — `CoreModule` now mounts them itself (§1). A refused refresh token or authorization code answers **400 `invalid_grant`** instead of **500** (§2). The MCP endpoint's 401 names the protected resource metadata, not the endpoint (§3) |
|
|
21
|
+
| **New Features** | Built-in consent page with sign-in for `/authorize` (§4); override `ai: { mcpOAuthService }` (§4); config `ai.mcp.oauthBaseUrl` (§1) and `ai.mcp.oauthLoginUrl` (§4) |
|
|
22
|
+
| **Security** | A code issued while answering a GET to `/authorize` is refused — consent must be a POST (§4). Without a signing secret, every environment except `local` / `ci` / `e2e` refuses to sign MCP tokens instead of using the public development key (§4). IAM sign-in / sign-up run Better-Auth's login-CSRF protection again (§5) |
|
|
23
|
+
| **Framework API** | New: `CoreAiMcpOAuthInitializer`, `mountAiMcpOAuthRouter()`, `renderMcpOAuthConsentPage()` + `IMcpOAuthConsentView`, `IAiMcpOAuthMountTarget`, `IAiMcpOAuthRouterOptions`, `IAiMcpOAuthConsentBinding`; on `CoreAiMcpOAuthService`: `isOAuthEnabled()`, `usesBuiltInConsent()`, protected `loadOAuthErrors()`, `renderConsentPage()`, `buildConsentView()`, `consentUser()`, `consentLoginUrl()`, `signConsentToken()`, `verifyConsentToken()`; `consumeAuthorizationCode(code, clientId?)`. `mountAiMcpOAuth()` is deprecated (a no-op once `CoreModule` mounted the router). No changed signatures |
|
|
24
|
+
| **Dependencies** | `@tus/server` 2.4.5 → 2.4.6 (typings fix). Lockfile: `follow-redirects` 1.16.0 → 1.16.1 (via `node-mailjet` → `axios`; GHSA-8r9p-f939-6h3c / GHSA-q3jh-wrc6-jgq8 — `Authorization` / `Cookie` forwarded on a cross-origin redirect). Run `pnpm update --depth Infinity follow-redirects` if your lockfile still holds 1.16.0 |
|
|
25
|
+
| **Breaking Changes** | With `ai.mcp.oauth`: see "What to check" in §1 — notably a deployed environment without `baseUrl` or without a signing secret (`develop` / `test` stages included) now fails the boot. For every project: a browser sign-in from an origin not in `trustedOrigins` now answers 403 (§5) |
|
|
26
|
+
| **Migration Effort** | Check that every browser origin that signs in is in `trustedOrigins` (§5). With `ai.mcp.oauth`: remove the `mountAiMcpOAuth()` call, check `NSC__BASE_URL`; the consent page comes built in (§4) |
|
|
27
|
+
|
|
28
|
+
## Quick Migration
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm update @lenne.tech/nest-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Vendor-mode projects pick it up with the core update. **Every project:** check that each browser origin that signs in is
|
|
35
|
+
trusted (§5). **With `ai.mcp.oauth: true`:** also §1 and §4.
|
|
36
|
+
|
|
37
|
+
## 1. The MCP OAuth endpoints are mounted by `CoreModule`
|
|
38
|
+
|
|
39
|
+
The OAuth 2.1 endpoints for MCP clients (`/.well-known/oauth-authorization-server`,
|
|
40
|
+
`/.well-known/oauth-protected-resource/ai/mcp`, `/authorize`, `/token`, `/register`) are an Express router. The
|
|
41
|
+
documentation said to mount it in `main.ts` **after** `app.init()`:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// main.ts — what the documentation prescribed until 11.42.16
|
|
45
|
+
await app.init();
|
|
46
|
+
await mountAiMcpOAuth(app);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That never worked in a Nest application. Nest registers its not-found handler inside `app.init()`; a router added
|
|
50
|
+
afterwards sits behind it and is never reached. Every OAuth endpoint answered Nest's 404, so no MCP client could
|
|
51
|
+
discover the server, register or get a token.
|
|
52
|
+
|
|
53
|
+
Since 11.43.0 `CoreAiMcpOAuthInitializer` (registered by `CoreAiModule` together with the MCP endpoint) mounts the
|
|
54
|
+
router during `app.init()` whenever `ai.mcp.oauth` is `true`: behind every Nest middleware (security headers, request
|
|
55
|
+
context, CORS) and the Nest routes, ahead of the not-found handler. It needs no `main.ts` code and works the same
|
|
56
|
+
under `Test.createTestingModule()`. Guards and interceptors do not run for it — the SDK's handlers authenticate the
|
|
57
|
+
client and rate-limit per IP themselves.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// main.ts — remove the call; it is a no-op now and logs that it can go
|
|
61
|
+
await mountAiMcpOAuth(app);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The protected MCP resource is described at the path Nest registered `/ai/mcp` at — global prefix and its exclusions,
|
|
65
|
+
URI versioning and a `RouterModule` path included. The issuer is the server's `baseUrl`, or the new
|
|
66
|
+
`ai.mcp.oauthBaseUrl` when MCP clients reach the server under another URL (its origin is added to Better-Auth's
|
|
67
|
+
trusted origins automatically, so the consent page can sign users in there; it replaces the `baseUrl` option of
|
|
68
|
+
`mountAiMcpOAuth()`, which is ignored with a warning once the router is mounted).
|
|
69
|
+
|
|
70
|
+
**What to check:**
|
|
71
|
+
|
|
72
|
+
- **A deployed environment without `baseUrl` now fails the boot** when `ai.mcp.oauth` is `true` — before, only the
|
|
73
|
+
`mountAiMcpOAuth()` call failed. Set `NSC__BASE_URL` (or `ai.mcp.oauthBaseUrl`). `local` / `ci` / `e2e` keep the
|
|
74
|
+
`http://localhost:3000` default.
|
|
75
|
+
- **The endpoints now exist.** With `ai.mcp.oauth: true`, dynamic client registration (`/register`) is reachable,
|
|
76
|
+
rate-limited per IP by the SDK — configure `trustProxy` behind a proxy, or all clients share one bucket — and so is
|
|
77
|
+
the consent page (§4): users can now authorize MCP clients.
|
|
78
|
+
- **CORS.** A project's `enableCors()` runs before the router, so a browser-based MCP client on an origin it does not
|
|
79
|
+
allow is refused at the preflight. Native clients (Claude Code, Claude Desktop) are unaffected.
|
|
80
|
+
- **Express only.** `ai.mcp.oauth` on another HTTP adapter fails the boot with a named error.
|
|
81
|
+
- **A route of your own at `/token`, `/register` or `/authorize`** now wins over the OAuth router, which comes after
|
|
82
|
+
the Nest routes.
|
|
83
|
+
|
|
84
|
+
## 2. Refusals answer `400 invalid_grant`, not `500`
|
|
85
|
+
|
|
86
|
+
The SDK's handlers answer an error with its OAuth code only when it is one of the SDK's own `OAuthError` classes;
|
|
87
|
+
anything else becomes `500 server_error`. The provider refused with plain `Error('invalid_grant')`, so an unknown,
|
|
88
|
+
rotated or foreign refresh token, or a code issued to another client, was answered with a 500. MCP clients discard
|
|
89
|
+
their stored tokens only on `invalid_grant`: on a 500, Claude Code kept the dead token, retried the refresh on every
|
|
90
|
+
start and never asked the user to sign in again.
|
|
91
|
+
|
|
92
|
+
Now: `invalid_grant` (400) for refresh tokens and codes, `invalid_client` for a missing client, `invalid_token` from
|
|
93
|
+
`verifyAccessToken`, and `access_denied` when an API token tries to approve a consent (the client is redirected back
|
|
94
|
+
with `error=access_denied` instead of `server_error`). `verifyAccessToken` also returns `expiresAt`, which the SDK's
|
|
95
|
+
bearer middleware requires. A code presented by another client is no longer deleted on the way to the refusal: the
|
|
96
|
+
exchange consumes only a code issued to the presenting client (`consumeAuthorizationCode(code, clientId)`), so the
|
|
97
|
+
owner can still redeem it.
|
|
98
|
+
|
|
99
|
+
**What to check:** code that matched the error MESSAGE (`/invalid_grant/`) — the code is in `error.errorCode` now, the
|
|
100
|
+
message is a description. An `authorizeConsent()` of your own must refuse the same way:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const { AccessDeniedError } = await this.loadOAuthErrors();
|
|
104
|
+
throw new AccessDeniedError('The user declined');
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 3. The MCP 401 points at the metadata document
|
|
108
|
+
|
|
109
|
+
`POST /ai/mcp` without credentials answered `WWW-Authenticate: Bearer resource_metadata="<request host>/ai/mcp"` — the
|
|
110
|
+
endpoint itself, built from the caller-controlled `Host` header. RFC 9728 §5.1 wants the URL of the protected resource
|
|
111
|
+
METADATA. It is now exactly the document the mounted router serves
|
|
112
|
+
(`https://api.example.com/.well-known/oauth-protected-resource/ai/mcp`), and a plain `Bearer` without OAuth, where no
|
|
113
|
+
such document exists.
|
|
114
|
+
|
|
115
|
+
**What to check:** a test or client that parsed the old header value.
|
|
116
|
+
|
|
117
|
+
## 4. A built-in consent page
|
|
118
|
+
|
|
119
|
+
`/authorize` needs a consent step: the page on which a user decides whether an MCP client may act for them. The
|
|
120
|
+
documentation told projects to override `authorizeConsent()` for it, but `CoreAiModule` offered no way to register a
|
|
121
|
+
subclass, so no project had one and no authorization could ever complete. The framework now brings the page itself:
|
|
122
|
+
|
|
123
|
+
- **Nobody signed in** → e-mail + password and the two-factor step (TOTP or backup code), against Better-Auth's own
|
|
124
|
+
endpoints on the API origin, then a reload of the pending request. Passkey and social login through the app's login
|
|
125
|
+
page: `ai.mcp.oauthLoginUrl`, default `<appUrl>/auth/login`, opened next to the consent page. A user already signed in
|
|
126
|
+
to the API origin skips this.
|
|
127
|
+
- **Signed in** → the account first (with "use another account"), which client asks, where the code goes; Allow /
|
|
128
|
+
Deny as a form POST. Allow counts only after intent on the page (visible, focused, a pointer move / key / touch at
|
|
129
|
+
least 600 ms later), against double-clickjacking; without JavaScript nothing can be approved.
|
|
130
|
+
- **Allow** issues the code only with the page's CSRF token, signed and bound to user, client, redirect URI and PKCE
|
|
131
|
+
challenge (10 minutes). **Deny** → `access_denied`.
|
|
132
|
+
|
|
133
|
+
The page sets its own CSP (nonces, `frame-ancestors 'none'`), `X-Frame-Options: DENY` and `Cache-Control: no-store`,
|
|
134
|
+
escapes every value from outside (the client's name comes from whoever registered it), and speaks German to a German
|
|
135
|
+
browser. It needs Better-Auth active; without it the boot warns.
|
|
136
|
+
|
|
137
|
+
**A signed-in session is not consent.** Dynamic registration is open, so anybody can register a client with their own
|
|
138
|
+
redirect URI and start the flow — PKCE is then theirs too. A GET to `/authorize` is what any link produces, and it
|
|
139
|
+
carries the SameSite=Lax session cookie. The framework therefore refuses a code saved while answering a GET to
|
|
140
|
+
`/authorize`: the client is sent back with `error=server_error`, and the server log names the refusal. That applies to
|
|
141
|
+
a consent page of your own as well.
|
|
142
|
+
|
|
143
|
+
**Your own page** (optional): override `renderConsentPage(view)` to restyle, or `authorizeConsent(client, params, res)`
|
|
144
|
+
to replace it, and register the subclass:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
CoreModule.forRoot(envConfig, { ai: { mcpOAuthService: McpOAuthService } })
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Two checks stay outside the overridable methods: an API token can never approve a consent, and the production secret
|
|
151
|
+
check runs again before the endpoints are mounted. Without a signing secret, every environment except `local`, `ci` and
|
|
152
|
+
`e2e` now refuses to boot with `ai.mcp.oauth` — and refuses to sign or verify tokens at all — instead of falling back
|
|
153
|
+
to the public development key. That includes the DEPLOYED `develop` and `test` stages of a starter-based project:
|
|
154
|
+
before, anybody who knew a user id could forge that user's MCP access token there.
|
|
155
|
+
|
|
156
|
+
## 5. IAM sign-in and sign-up run Better-Auth's login-CSRF protection
|
|
157
|
+
|
|
158
|
+
Better-Auth protects `sign-in/email` and `sign-up/email` itself (`formCsrfMiddleware`): it refuses a cross-site
|
|
159
|
+
navigation (`Sec-Fetch-Site: cross-site` + `Sec-Fetch-Mode: navigate`, i.e. a form a hostile page submits) and checks
|
|
160
|
+
a present `Origin` / `Referer` against `trustedOrigins`. It reads both from the request — and
|
|
161
|
+
`CoreBetterAuthController` called `api.signInEmail()` / `api.signUpEmail()` without one, so the check never ran. A
|
|
162
|
+
hostile page could sign a visitor in to an account of the attacker's choosing (login CSRF); whatever the visitor did
|
|
163
|
+
next, approving an MCP client on the consent page included, happened in the attacker's account.
|
|
164
|
+
|
|
165
|
+
The controller now hands Better-Auth those headers (`Origin`, `Referer`, `Sec-Fetch-*` — not the cookie, see below). A
|
|
166
|
+
refused request answers **403** (`#LTNS_0101`, `ErrorCode.ACCESS_DENIED`) instead of a session, and the server logs the reason.
|
|
167
|
+
|
|
168
|
+
**What to check:**
|
|
169
|
+
|
|
170
|
+
- **Every origin that signs in from a browser must be trusted.** Trusted by default: the API's own origin, `appUrl`,
|
|
171
|
+
every origin in `cors.allowedOrigins` (merged with `appUrl` and `baseUrl`) and, with `ai.mcp.oauth`, the origin of
|
|
172
|
+
`ai.mcp.oauthBaseUrl`. A frontend on any other origin — a second web app, a native app's web view
|
|
173
|
+
(`capacitor://localhost`, `http://localhost`), a preview deployment — now answers 403 where it used to succeed.
|
|
174
|
+
Add it to **`cors.allowedOrigins`**; it needs that entry for credentialed CORS anyway. `cors.allowAll: true` does not
|
|
175
|
+
trust arbitrary origins for sign-in — an origin check has no "allow everything" mode.
|
|
176
|
+
- **Do not reach for `betterAuth.trustedOrigins` for this unless you list everything.** An explicit list REPLACES the
|
|
177
|
+
derived one: `['capacitor://localhost']` alone stops trusting `appUrl` and `cors.allowedOrigins`, and every sign-in from
|
|
178
|
+
the main web app then answers 403 — with the project's test suite still green (see "Test runners" below). If you set
|
|
179
|
+
it, include `appUrl` and every other frontend origin.
|
|
180
|
+
- **Non-browser clients are unaffected:** a request without `Origin`, `Referer` and `Sec-Fetch-Site` (server-to-server,
|
|
181
|
+
API tests, mobile HTTP clients, scripts and SSR routes using Node's built-in `fetch`) is not checked. Node's `fetch`
|
|
182
|
+
sends a lone `Sec-Fetch-Mode: cors`; that is not a browser signal and is not forwarded, because Better-Auth would read
|
|
183
|
+
it as a browser request missing its origin. The cookie is deliberately not forwarded either: its presence alone makes
|
|
184
|
+
Better-Auth demand an `Origin`, which would refuse such clients when they keep a cookie jar.
|
|
185
|
+
- **Test runners:** Better-Auth skips this check under a test runner by default (`isTest()`), so project test suites
|
|
186
|
+
behave as before; set `betterAuth.options.advanced.disableOriginCheck: false` in a spec that must see production
|
|
187
|
+
behaviour.
|
|
188
|
+
- A subclass overriding `signIn()` / `signUp()` that calls `super` keeps the protection; one that calls
|
|
189
|
+
`api.signInEmail()` itself should pass `request: this.csrfCheckedRequest(req, '/sign-in/email')` and
|
|
190
|
+
`asResponse: false` the same way.
|
|
191
|
+
|
|
192
|
+
## Module Documentation
|
|
193
|
+
|
|
194
|
+
- [AI module README — OAuth 2.1](../src/core/modules/ai/README.md)
|
|
195
|
+
- [AI module integration checklist — MCP OAuth 2.1](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.43.0",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@nestjs/swagger": "11.4.7",
|
|
106
106
|
"@nestjs/terminus": "11.1.1",
|
|
107
107
|
"@tus/file-store": "2.1.1",
|
|
108
|
-
"@tus/server": "2.4.
|
|
108
|
+
"@tus/server": "2.4.6",
|
|
109
109
|
"@types/supertest": "7.2.1",
|
|
110
110
|
"bcrypt": "6.0.0",
|
|
111
111
|
"class-transformer": "0.5.1",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"@nestjs/testing": "11.2.7",
|
|
194
194
|
"@swc/cli": "0.8.1",
|
|
195
195
|
"@swc/core": "1.16.18",
|
|
196
|
-
"@tus/s3-store": "2.0.
|
|
196
|
+
"@tus/s3-store": "2.0.8",
|
|
197
197
|
"@types/compression": "1.8.1",
|
|
198
198
|
"@types/cookie-parser": "1.4.10",
|
|
199
199
|
"@types/ejs": "3.1.5",
|
|
@@ -202,15 +202,15 @@
|
|
|
202
202
|
"@types/multer": "2.3.0",
|
|
203
203
|
"@types/node": "26.6.5",
|
|
204
204
|
"@types/passport": "1.0.17",
|
|
205
|
-
"@vitest/coverage-v8": "4.1.
|
|
205
|
+
"@vitest/coverage-v8": "4.1.12",
|
|
206
206
|
"better-auth": "1.7.7",
|
|
207
|
-
"bullmq": "6.3.
|
|
207
|
+
"bullmq": "6.3.16",
|
|
208
208
|
"cross-env": "10.1.0",
|
|
209
209
|
"ioredis": "6.0.0",
|
|
210
210
|
"nodemon": "3.1.14",
|
|
211
211
|
"npm-watch": "0.13.0",
|
|
212
212
|
"otpauth": "9.5.2",
|
|
213
|
-
"oxfmt": "0.
|
|
213
|
+
"oxfmt": "0.73.0",
|
|
214
214
|
"oxlint": "1.88.0",
|
|
215
215
|
"rimraf": "6.1.3",
|
|
216
216
|
"smtp-server": "3.19.23",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
"unplugin-swc": "2.0.0",
|
|
223
223
|
"vite": "8.3.4",
|
|
224
224
|
"vite-plugin-node": "8.0.0",
|
|
225
|
-
"vitest": "4.1.
|
|
225
|
+
"vitest": "4.1.12"
|
|
226
226
|
},
|
|
227
227
|
"main": "dist/index.js",
|
|
228
228
|
"types": "dist/index.d.ts",
|