@lenne.tech/nest-server 11.42.16 → 11.43.1
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 +3 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/interfaces/server-options.interface.d.ts +3 -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/modules/better-auth/core-better-auth.service.js +9 -0
- 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 +1 -1
- package/migration-guides/11.42.x-to-11.43.x.md +195 -0
- package/migration-guides/11.43.0-to-11.43.1.md +73 -0
- package/package.json +4 -4
- package/src/core/common/helpers/swagger.helper.ts +4 -4
- package/src/core/common/interfaces/server-options.interface.ts +37 -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/modules/better-auth/core-better-auth.service.ts +21 -3
|
@@ -179,7 +179,7 @@ JWT-based authentication for existing projects:
|
|
|
179
179
|
| **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
|
|
180
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` |
|
|
181
181
|
| **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
|
|
182
|
-
| **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) |
|
|
183
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`) |
|
|
184
184
|
|
|
185
185
|
### Email & Templates
|
|
@@ -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)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Migration Guide: 11.43.0 → 11.43.1
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| **Bugfixes** | Bearer JWTs issued by Better-Auth are accepted again when only the server's `baseUrl` is set (`NSC__BASE_URL`, every `lt dev up`) — they answered **401** on every request since 11.10.3 (§1) |
|
|
8
|
+
| **New Features** | None |
|
|
9
|
+
| **Framework API** | `CoreBetterAuthService.getBaseUrl()` returns the ORIGIN of the base URL the Better-Auth instance runs on (§1). No changed signatures, no new exports |
|
|
10
|
+
| **Dependencies** | Unchanged |
|
|
11
|
+
| **Breaking Changes** | None |
|
|
12
|
+
| **Migration Effort** | None. A workaround that set `betterAuth.baseUrl` next to `baseUrl` can be removed (§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.
|
|
21
|
+
|
|
22
|
+
## 1. Bearer JWTs answered 401 when only the server `baseUrl` was set
|
|
23
|
+
|
|
24
|
+
Better-Auth signs every JWT with `iss` and `aud` set to the base URL it runs on. `createBetterAuthInstance()`
|
|
25
|
+
resolves that base URL as `betterAuth.baseUrl` → the server's `baseUrl` → the localhost default, and Better-Auth
|
|
26
|
+
stores the **origin** of the result. The framework verified Bearer JWTs against
|
|
27
|
+
`CoreBetterAuthService.getBaseUrl()`, which read only `betterAuth.baseUrl` and otherwise answered
|
|
28
|
+
`http://localhost:3000`.
|
|
29
|
+
|
|
30
|
+
So as soon as a deployment set the server's `baseUrl` without also setting `betterAuth.baseUrl`, the two disagreed,
|
|
31
|
+
and every Bearer JWT was refused with `401 #LTNS_0100` (`unexpected "iss" claim value` in the debug log). That is the
|
|
32
|
+
configuration `nest-server-starter` ships (`baseUrl: process.env.BASE_URL`), every stage that sets `NSC__BASE_URL`,
|
|
33
|
+
and every `lt dev up`. Cookie sessions were unaffected, so the defect surfaced only for clients in JWT mode — for
|
|
34
|
+
example `@lenne.tech/nuxt-extensions` once it switches to JWT after the login, which then received 401 on every
|
|
35
|
+
request.
|
|
36
|
+
|
|
37
|
+
`getBaseUrl()` now reads the base URL off the live Better-Auth instance and returns its origin — exactly the value
|
|
38
|
+
Better-Auth signs with. A trailing slash, a path or a default port in the configured value
|
|
39
|
+
(`https://api.example.com/`, `https://example.com/api`, `https://api.example.com:443`) is not part of the claim and
|
|
40
|
+
no longer causes a mismatch either.
|
|
41
|
+
|
|
42
|
+
**What to check:**
|
|
43
|
+
|
|
44
|
+
- **You set `betterAuth.baseUrl` (or `NSC__BETTER_AUTH__BASE_URL`) only to work around this:** it can go. It still
|
|
45
|
+
wins over the server's `baseUrl` when set, so leaving it in place changes nothing.
|
|
46
|
+
- **Your own code calls `getBaseUrl()`:** it now returns the URL Better-Auth actually runs on, as an origin. Code
|
|
47
|
+
that joined an absolute path onto it (`new URL('/iam/sign-in/email', getBaseUrl())`) keeps working; code that
|
|
48
|
+
relied on a path being part of the returned value must append that path itself. With only the server's `baseUrl`
|
|
49
|
+
set, it now answers that URL instead of `http://localhost:3000`.
|
|
50
|
+
- **A vendor-mode project patched `getBaseUrl()` locally** for the same reason: the patch becomes obsolete with this
|
|
51
|
+
core update and can be dropped.
|
|
52
|
+
|
|
53
|
+
## Compatibility Notes
|
|
54
|
+
|
|
55
|
+
| Pattern | Status |
|
|
56
|
+
| ------- | ------ |
|
|
57
|
+
| Only `baseUrl` / `NSC__BASE_URL` set (starter default, `lt dev up`) | Bearer JWTs accepted again |
|
|
58
|
+
| `betterAuth.baseUrl` set explicitly | Unchanged |
|
|
59
|
+
| Neither set (`local` / `ci` / `e2e` defaults) | Unchanged — both sides use `http://localhost:3000` |
|
|
60
|
+
| Cookie-only clients | Unchanged |
|
|
61
|
+
| A JWT naming another issuer | Still refused |
|
|
62
|
+
|
|
63
|
+
## Troubleshooting
|
|
64
|
+
|
|
65
|
+
**Bearer requests still answer 401 after the update**
|
|
66
|
+
Decode the token (`iss` claim) and compare it with `getBaseUrl()`. They must be identical. If they differ, check
|
|
67
|
+
whether `betterAuth.options.baseURL` overrides the base URL with something that is not an absolute URL — then the
|
|
68
|
+
framework falls back to the configured value.
|
|
69
|
+
|
|
70
|
+
## Module Documentation
|
|
71
|
+
|
|
72
|
+
- [Better-Auth module](../src/core/modules/better-auth/README.md)
|
|
73
|
+
- Implementation: `src/core/modules/better-auth/core-better-auth.service.ts` → `getBaseUrl()`, `verifyJwksToken()`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.43.1",
|
|
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",
|
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
"@types/passport": "1.0.17",
|
|
205
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",
|
|
@@ -60,8 +60,8 @@ import type { ISwaggerApiTokenViewOptions, ISwaggerSetupOptions } from '../inter
|
|
|
60
60
|
* - With multi-tenancy on, every operation that reads the tenant header documents it as a parameter.
|
|
61
61
|
* - The routes Better-Auth serves itself (two-factor, passkey, sessions, password change, … — whatever the
|
|
62
62
|
* configured plugins add) are documented from Better-Auth's own OpenAPI generator, under the project's
|
|
63
|
-
* Better-Auth controller (since 11.42.9); so are `/ai/mcp` and the MCP OAuth endpoints
|
|
64
|
-
*
|
|
63
|
+
* Better-Auth controller (since 11.42.9); so are `/ai/mcp` and the MCP OAuth endpoints that are
|
|
64
|
+
* actually mounted (by `CoreModule` when `ai.mcp.oauth` is enabled).
|
|
65
65
|
* - Only what can succeed under the running configuration is documented (since 11.42.9): an `@All()`
|
|
66
66
|
* handler keeps the methods `@ApiMethods()` names; legacy `/auth/*` disappears while
|
|
67
67
|
* `auth.legacyEndpoints` keeps REST closed; Better-Auth routes disappear when the configuration or the
|
|
@@ -437,8 +437,8 @@ function declaresSecurity(reference: IHandlerReference): boolean {
|
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
/**
|
|
440
|
-
* Documents the MCP OAuth 2.1 endpoints
|
|
441
|
-
* nothing else. They authenticate the client themselves, so they carry no security requirement.
|
|
440
|
+
* Documents the MCP OAuth 2.1 endpoints mounted on Express (`CoreAiMcpOAuthInitializer`, or a manual
|
|
441
|
+
* `mountAiMcpOAuth()`) — the ones mounted, nothing else. They authenticate the client themselves, so they carry no security requirement.
|
|
442
442
|
*/
|
|
443
443
|
function addMcpOAuthRoutes(
|
|
444
444
|
document: OpenAPIObject,
|
|
@@ -2114,12 +2114,36 @@ export interface IAi {
|
|
|
2114
2114
|
* external MCP clients. Requires `@modelcontextprotocol/sdk`. Boolean shorthand.
|
|
2115
2115
|
*
|
|
2116
2116
|
* `oauth: true` additionally enables the OAuth 2.1 layer (dynamic client
|
|
2117
|
-
* registration + PKCE) so generic MCP clients can authenticate
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2117
|
+
* registration + PKCE) so generic MCP clients can authenticate. `CoreModule` mounts
|
|
2118
|
+
* its endpoints (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`) during
|
|
2119
|
+
* `app.init()` — no `main.ts` code (11.43.0+). The issuer is `oauthBaseUrl`, else
|
|
2120
|
+
* the server's `baseUrl`; a deployed environment with neither fails the boot. `/authorize`
|
|
2121
|
+
* answers with a built-in consent page: sign-in through Better-Auth (password + 2FA),
|
|
2122
|
+
* then Allow / Deny. Replace it via `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`.
|
|
2123
|
+
* Without `oauth`, MCP authenticates via the existing Bearer/session token.
|
|
2120
2124
|
* @default false
|
|
2121
2125
|
*/
|
|
2122
|
-
mcp?:
|
|
2126
|
+
mcp?:
|
|
2127
|
+
| boolean
|
|
2128
|
+
| {
|
|
2129
|
+
enabled?: boolean;
|
|
2130
|
+
oauth?: boolean;
|
|
2131
|
+
/**
|
|
2132
|
+
* Public base URL of this server for the OAuth issuer and the MCP resource, when MCP
|
|
2133
|
+
* clients reach it under a different URL than `baseUrl`. Blank counts as unset.
|
|
2134
|
+
* @default the server's `baseUrl` (`http://localhost:3000` in local / ci / e2e)
|
|
2135
|
+
*/
|
|
2136
|
+
oauthBaseUrl?: string;
|
|
2137
|
+
/**
|
|
2138
|
+
* The app's login page, linked from the consent page for sign-in methods it does not
|
|
2139
|
+
* offer itself (passkey, social login). A `{returnTo}` in it is replaced by the URL of the
|
|
2140
|
+
* pending `/authorize` request, for a login page that sends the user back by itself;
|
|
2141
|
+
* without it the link opens next to the consent page, which then offers "continue".
|
|
2142
|
+
* @default `<appUrl>/auth/login` when an app URL can be resolved
|
|
2143
|
+
*/
|
|
2144
|
+
oauthLoginUrl?: string;
|
|
2145
|
+
oauthSecret?: string;
|
|
2146
|
+
};
|
|
2123
2147
|
|
|
2124
2148
|
/** Rate limiting for prompts. */
|
|
2125
2149
|
rateLimit?: IAiRateLimit;
|
|
@@ -4559,10 +4583,11 @@ interface IBetterAuthWithoutPasskey extends IBetterAuthBase {
|
|
|
4559
4583
|
* Trusted origins for CORS configuration.
|
|
4560
4584
|
*
|
|
4561
4585
|
* Optional when Passkey is disabled.
|
|
4562
|
-
* If not set, all origins
|
|
4586
|
+
* If not set, it is derived (see below) — never "all origins": an origin check has no such mode.
|
|
4563
4587
|
*
|
|
4564
|
-
* Auto-derived
|
|
4565
|
-
*
|
|
4588
|
+
* Auto-derived when not set: `appUrl`, `baseUrl` and `cors.allowedOrigins` (plus the passkey
|
|
4589
|
+
* origins and, with `ai.mcp.oauth`, the origin of `ai.mcp.oauthBaseUrl`). An explicit list
|
|
4590
|
+
* REPLACES the derived one — include `appUrl` and every frontend origin, or their sign-ins answer 403.
|
|
4566
4591
|
*
|
|
4567
4592
|
* **Important for cross-subdomain setups:** When using `crossSubDomainCookies`,
|
|
4568
4593
|
* cookies are shared across subdomains, but the server also needs to accept requests
|
|
@@ -4621,8 +4646,9 @@ interface IBetterAuthWithPasskey extends IBetterAuthBase {
|
|
|
4621
4646
|
* Passkey uses `credentials: 'include'` which requires explicit CORS origins.
|
|
4622
4647
|
* Browsers don't allow wildcard `*` with credentials.
|
|
4623
4648
|
*
|
|
4624
|
-
* Auto-derived
|
|
4625
|
-
*
|
|
4649
|
+
* Auto-derived when not set: `appUrl`, `baseUrl` and `cors.allowedOrigins` (plus the passkey
|
|
4650
|
+
* origins and, with `ai.mcp.oauth`, the origin of `ai.mcp.oauthBaseUrl`). An explicit list
|
|
4651
|
+
* REPLACES the derived one — include `appUrl` and every frontend origin, or their sign-ins answer 403.
|
|
4626
4652
|
*
|
|
4627
4653
|
* **Important for cross-subdomain setups:** When using `crossSubDomainCookies`,
|
|
4628
4654
|
* cookies are shared across subdomains, but the server also needs to accept requests
|
|
@@ -4703,6 +4729,7 @@ export interface ICoreModuleOverrides {
|
|
|
4703
4729
|
* - `conversationService` must extend `CoreAiConversationService`
|
|
4704
4730
|
* - `interactionService` must extend `CoreAiInteractionService`
|
|
4705
4731
|
* - `mcpClientService` must extend `CoreAiMcpClientService`
|
|
4732
|
+
* - `mcpOAuthService` must extend `CoreAiMcpOAuthService` (implements `authorizeConsent()`)
|
|
4706
4733
|
* - `modeService` must extend `CoreAiModeService`
|
|
4707
4734
|
* - `placeholderRegistry` must extend `CoreAiPlaceholderRegistry`
|
|
4708
4735
|
* - `preferenceService` must extend `CoreAiConnectionPreferenceService`
|
|
@@ -4735,6 +4762,7 @@ export interface ICoreModuleOverrides {
|
|
|
4735
4762
|
conversationService?: Type<any>;
|
|
4736
4763
|
interactionService?: Type<any>;
|
|
4737
4764
|
mcpClientService?: Type<any>;
|
|
4765
|
+
mcpOAuthService?: Type<any>;
|
|
4738
4766
|
modeService?: Type<any>;
|
|
4739
4767
|
placeholderRegistry?: Type<any>;
|
|
4740
4768
|
preferenceService?: Type<any>;
|
|
@@ -44,8 +44,10 @@ rest. Without `ai.encryptionSecret` / `NSC__AI__ENCRYPTION_SECRET` /
|
|
|
44
44
|
`SECRETS_ENCRYPTION_KEY`, an insecure development default is used (logged as a
|
|
45
45
|
warning) in non-production environments. In **production/staging the app refuses to
|
|
46
46
|
boot** when AI is enabled but no secret is set (mirrors the email/cookie production
|
|
47
|
-
guards) — so a missing secret can never silently ship. The
|
|
48
|
-
|
|
47
|
+
guards) — so a missing secret can never silently ship. The MCP OAuth signing secret
|
|
48
|
+
(`ai.mcp.oauth`) is stricter: it is required everywhere except `local`, `ci` and `e2e`,
|
|
49
|
+
deployed `develop` / `test` stages included — a public key there would let anybody forge
|
|
50
|
+
an MCP access token for any user id.
|
|
49
51
|
|
|
50
52
|
### 2. Set the LLM API key (environment)
|
|
51
53
|
|
|
@@ -205,21 +207,42 @@ The response body carries the stable `#LTNS_0901` code, never the raw error.
|
|
|
205
207
|
|
|
206
208
|
### MCP OAuth 2.1 (only when `ai.mcp.oauth` is enabled)
|
|
207
209
|
|
|
208
|
-
**
|
|
210
|
+
**No `main.ts` step.** `CoreModule` mounts the OAuth endpoints itself during `app.init()`
|
|
211
|
+
(11.43.0+). Remove a `mountAiMcpOAuth(app)` call from `main.ts` — it is a no-op now.
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
**WHY not in `main.ts` any more:** the endpoints are an Express router, and Nest registers its
|
|
214
|
+
not-found handler inside `app.init()`. Mounted after `app.init()`, as this checklist used to say,
|
|
215
|
+
the router sat behind that handler and every OAuth endpoint answered 404.
|
|
216
|
+
|
|
217
|
+
**Check `NSC__BASE_URL` in every deployed environment.** The issuer is every URL in the discovery
|
|
218
|
+
metadata, and MCP clients follow them. It is the server's `baseUrl`, and a deployed environment
|
|
219
|
+
without one fails the boot rather than advertising `localhost` — which sends clients to the user's
|
|
220
|
+
own machine. Set `ai.mcp.oauthBaseUrl` only when MCP clients reach the server under another URL;
|
|
221
|
+
never add a `localhost` fallback of your own.
|
|
222
|
+
|
|
223
|
+
**Nothing to create for consent.** `/authorize` answers with a built-in consent page: sign-in through
|
|
224
|
+
Better-Auth (password + TOTP / backup code), then Allow / Deny with a CSRF token bound to user, client,
|
|
225
|
+
redirect URI and PKCE challenge. It needs Better-Auth active (the boot warns otherwise).
|
|
226
|
+
|
|
227
|
+
**Optional — `ai.mcp.oauthLoginUrl`:** the link for passkey / social login, default `<appUrl>/auth/login`.
|
|
228
|
+
Put `{returnTo}` into it only if that login page can send the user back to the API's `/authorize` URL
|
|
229
|
+
(the nuxt-base-starter login accepts same-origin targets only, so leave it out there).
|
|
230
|
+
|
|
231
|
+
**Optional — your own consent UI:** a subclass of `CoreAiMcpOAuthService` overriding
|
|
232
|
+
`renderConsentPage(view)` (restyle) or `authorizeConsent(client, params, res)` (replace), registered via
|
|
233
|
+
`CoreModule.forRoot(envConfig, { ai: { mcpOAuthService: McpOAuthService } })`.
|
|
214
234
|
|
|
215
|
-
**WHY
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
235
|
+
**WHY a POST, never "signed in, so issue":** anybody can register a client with their own redirect
|
|
236
|
+
URI and start the flow — PKCE is then theirs too. A GET to `/authorize` is what any link produces,
|
|
237
|
+
and it carries the session cookie, so issuing on GET hands a code for whoever clicks to the
|
|
238
|
+
attacker. The framework refuses a code saved while answering a GET to `/authorize` (the client gets
|
|
239
|
+
`error=server_error`); an own consent UI must show WHICH client is approved and protect the POST
|
|
240
|
+
with a CSRF token.
|
|
220
241
|
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
**WHY refuse with `this.loadOAuthErrors()`:** the SDK maps an error to its OAuth code only when it
|
|
243
|
+
is one of its own `OAuthError` classes, and turns anything else into `server_error`. A user who
|
|
244
|
+
declines consent should send the client back with `access_denied` (`AccessDeniedError`), not with
|
|
245
|
+
a server error.
|
|
223
246
|
|
|
224
247
|
## Verification Checklist
|
|
225
248
|
|
|
@@ -237,13 +260,16 @@ browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are bui
|
|
|
237
260
|
|
|
238
261
|
## Common Mistakes
|
|
239
262
|
|
|
240
|
-
| Mistake
|
|
241
|
-
|
|
|
242
|
-
| No `ai` config block
|
|
243
|
-
| No encryption secret in prod
|
|
244
|
-
| Encryption secret changed after keys stored
|
|
245
|
-
| Tool returns `.lean()`/aggregate data
|
|
246
|
-
| Tool not registered
|
|
247
|
-
| Overridden resolver method missing decorators
|
|
248
|
-
| Storing a real API key in `config.env.ts`
|
|
249
|
-
| `localhost` fallback for the MCP OAuth `baseUrl`
|
|
263
|
+
| Mistake | Symptom | Fix |
|
|
264
|
+
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
265
|
+
| No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
|
|
266
|
+
| No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
|
|
267
|
+
| Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
|
|
268
|
+
| Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
|
|
269
|
+
| Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
|
|
270
|
+
| Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
|
|
271
|
+
| Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
|
|
272
|
+
| `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Set `NSC__BASE_URL`; no `ai.mcp.oauthBaseUrl` pointing at `localhost` |
|
|
273
|
+
| `mountAiMcpOAuth(app)` after `app.init()` (pre-11.43.0) | `/.well-known/oauth-*`, `/token`, `/register` answer Nest's 404 | Upgrade; remove the call — `CoreModule` mounts the endpoints during `app.init()` |
|
|
274
|
+
| Own consent issues the code on GET ("signed in = consent") | `/authorize` redirects back with `error=server_error`; the refusal is logged | Show the consent page on GET, issue only on the approving POST with a CSRF token |
|
|
275
|
+
| `ai.mcp.oauth` without Better-Auth | Boot warning; the consent page offers no sign-in | Enable `betterAuth`, or register an own `authorizeConsent()` |
|