@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.
Files changed (55) hide show
  1. package/.claude/rules/better-auth.md +22 -0
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +2 -2
  5. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  6. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
  7. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
  8. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
  9. package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
  10. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  11. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  12. package/dist/core/modules/ai/core-ai.module.js +6 -1
  13. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
  15. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
  16. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
  18. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
  19. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  20. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
  21. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
  22. package/dist/core/modules/ai/index.d.ts +1 -0
  23. package/dist/core/modules/ai/index.js +1 -0
  24. package/dist/core/modules/ai/index.js.map +1 -1
  25. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
  26. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
  27. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  28. package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
  29. package/dist/core/modules/better-auth/better-auth.config.js +29 -2
  30. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  31. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  32. package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
  33. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  34. package/dist/core/modules/better-auth/core-better-auth.service.js +9 -0
  35. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  36. package/dist/tsconfig.build.tsbuildinfo +1 -1
  37. package/docs/REQUEST-LIFECYCLE.md +1 -1
  38. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  39. package/migration-guides/11.43.0-to-11.43.1.md +73 -0
  40. package/package.json +4 -4
  41. package/src/core/common/helpers/swagger.helper.ts +4 -4
  42. package/src/core/common/interfaces/server-options.interface.ts +37 -9
  43. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  44. package/src/core/modules/ai/README.md +98 -13
  45. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  46. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  47. package/src/core/modules/ai/core-ai.module.ts +14 -2
  48. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  49. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  50. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  51. package/src/core/modules/ai/index.ts +1 -0
  52. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  53. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  54. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
  55. 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); optional OAuth 2.1 router mounted in `main.ts` via `mountAiMcpOAuth(app)` (`/.well-known/*`, `/authorize`, `/token`, `/register`) |
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.42.16",
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.5",
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.7",
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.15",
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
- * `mountAiMcpOAuth()` mounted.
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 `mountAiMcpOAuth()` mounted on Express — the ones it mounted,
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; mount the OAuth
2118
- * router in `main.ts` via `mountAiMcpOAuth(app)` (see INTEGRATION-CHECKLIST).
2119
- * Without it, MCP authenticates via the existing Bearer/session token.
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?: boolean | { enabled?: boolean; oauth?: boolean; oauthSecret?: string };
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 are allowed (CORS `*`).
4586
+ * If not set, it is derived (see below) — never "all origins": an origin check has no such mode.
4563
4587
  *
4564
- * Auto-derived from `baseUrl` and `appUrl` when these are configured.
4565
- * Explicitly listed origins are merged with the auto-derived ones.
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 from `baseUrl` and `appUrl` when these are configured.
4625
- * Explicitly listed origins are merged with the auto-derived ones.
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 same applies to the MCP
48
- OAuth signing secret when `ai.mcp.oauth` is enabled.
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
- **Edit `main.ts`** (after `app.init()`):
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
- ```typescript
211
- import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
212
- await mountAiMcpOAuth(app);
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 no `baseUrl` argument:** the issuer is every URL in the discovery metadata, and MCP clients
216
- follow them. The helper takes it from the server's `baseUrl` (`NSC__BASE_URL` when deployed) and
217
- fails the boot in a deployed environment that has none, rather than advertising `localhost` —
218
- which sends clients to the user's own machine. Pass `{ baseUrl }` only for an issuer that must
219
- differ from the server's `baseUrl`; never add a `localhost` fallback of your own.
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
- Override `CoreAiMcpOAuthService.authorizeConsent()` with your login/consent UI (the only
222
- browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are built in.
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 | Symptom | Fix |
241
- | ------------------------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
242
- | No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
243
- | No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
244
- | 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 |
245
- | Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
246
- | Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
247
- | Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
248
- | Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
249
- | `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Call `mountAiMcpOAuth(app)` without `baseUrl`, set `NSC__BASE_URL` |
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()` |