@lenne.tech/nest-server 11.42.16 → 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.
Files changed (51) 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/tsconfig.build.tsbuildinfo +1 -1
  35. package/docs/REQUEST-LIFECYCLE.md +1 -1
  36. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  37. package/package.json +4 -4
  38. package/src/core/common/helpers/swagger.helper.ts +4 -4
  39. package/src/core/common/interfaces/server-options.interface.ts +37 -9
  40. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  41. package/src/core/modules/ai/README.md +98 -13
  42. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  43. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  44. package/src/core/modules/ai/core-ai.module.ts +14 -2
  45. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  46. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  47. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  48. package/src/core/modules/ai/index.ts +1 -0
  49. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  50. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  51. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
@@ -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)
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.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.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()` |
@@ -685,32 +685,117 @@ Without Redis nothing changes: unknown ids answer `404` exactly as before.
685
685
  For generic MCP clients that auto-discover + register:
686
686
  `CoreAiMcpOAuthService` provides HMAC-signed access tokens (constant-time verify),
687
687
  PKCE S256, and MongoDB-backed client/code/refresh stores; the MCP controller then
688
- also accepts OAuth access tokens. Mount the discovery/token endpoints in `main.ts`:
688
+ also accepts OAuth access tokens.
689
689
 
690
690
  ```typescript
691
- // main.ts, after app.init()
692
- import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
693
- await mountAiMcpOAuth(app);
691
+ // config.env.ts — that is all: endpoints, consent page and sign-in come with it
692
+ ai: { mcp: { oauth: true, oauthSecret: process.env.NSC__AI__ENCRYPTION_SECRET } }
694
693
  ```
695
694
 
695
+ **Mounted by `CoreModule` (11.43.0).** `CoreAiMcpOAuthInitializer` mounts the discovery and token
696
+ endpoints (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource<mcp path>`,
697
+ `/authorize`, `/token`, `/register`) during `app.init()` — no `main.ts` code, and the same under
698
+ `Test.createTestingModule()`. The position is the point: the endpoints are an Express router, and Nest
699
+ registers its not-found handler inside `app.init()`. The call this replaces — `mountAiMcpOAuth(app)` after
700
+ `app.init()`, as this README used to say — sat behind that handler, so every OAuth endpoint answered 404.
701
+ Mounted during init, the router sits behind every Nest middleware (security headers, request context,
702
+ CORS) and the Nest routes, and ahead of the not-found handler. Guards and interceptors do not run for it;
703
+ the SDK's handlers authenticate the client and rate-limit per IP themselves (so `trustProxy` matters
704
+ behind a proxy). A `mountAiMcpOAuth()` call left in `main.ts` is a no-op and logs that it can go; options
705
+ it passes that differ from the mount (`baseUrl`, `mcpPath`) are named in a warning.
706
+
707
+ The protected MCP resource is described at the path Nest registered the endpoint at — global prefix and
708
+ its exclusions, URI versioning and a `RouterModule` path included — and the endpoint's `401` names that
709
+ metadata document in `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1). Without OAuth the
710
+ challenge is a plain `Bearer`: there is no metadata to point at. CORS: a project's `enableCors()` runs
711
+ first, so a browser-based MCP client on an origin it does not allow is refused at the preflight; native
712
+ clients (Claude Code, Claude Desktop) send none.
713
+
696
714
  **Swagger (11.42.9):** `setupSwagger()` documents `/ai/mcp` (POST/GET/DELETE, with the bearer
697
715
  requirement the endpoint enforces itself) whenever the MCP server is registered, and — because the
698
- OAuth endpoints are an Express router, invisible to `@nestjs/swagger` — exactly the OAuth endpoints
699
- `mountAiMcpOAuth()` mounted (`/.well-known/oauth-authorization-server`,
700
- `/.well-known/oauth-protected-resource/ai/mcp`, `/authorize`, `/token`, `/register`; `/revoke` only for a
701
- provider with `revokeToken`). Without the call they are not documented, because they do not exist.
716
+ OAuth endpoints are an Express router, invisible to `@nestjs/swagger` — exactly the OAuth endpoints that
717
+ are mounted (`/revoke` only for a provider with `revokeToken`). Without `ai.mcp.oauth` they are not
718
+ documented, because they do not exist.
702
719
 
703
720
  The issuer — and with it every endpoint URL in the discovery metadata, which MCP clients follow —
704
721
  is the server's `baseUrl` (`NSC__BASE_URL` when deployed), resolved the same way BetterAuth and
705
722
  CORS resolve it. `local` / `ci` / `e2e` fall back to `http://localhost:3000`; any other environment
706
- without a `baseUrl` fails the call instead of guessing, because a deployed API that advertised
723
+ without a `baseUrl` fails the boot instead of guessing, because a deployed API that advertised
707
724
  `http://localhost:3000` sent Claude Code off to register the client on the user's own machine.
708
- Pass `{ baseUrl }` only when the issuer must differ from the server's `baseUrl`, and do not add a
709
- `localhost` fallback of your own. The resolved issuer is logged once at boot.
710
-
711
- Override `CoreAiMcpOAuthService.authorizeConsent()` to wire your login/consent UI.
725
+ Set `ai.mcp.oauthBaseUrl` only when MCP clients reach the server under another URL than `baseUrl` (its
726
+ origin is then trusted by Better-Auth automatically, because the consent page signs users in there),
727
+ and do not add a `localhost` fallback of your own. Issuer and resource are logged once at boot.
712
728
  Set `ai.mcp.oauthSecret` (or reuse `ai.encryptionSecret`) to a random 32+ char value.
713
729
 
730
+ **Consent page (11.43.0).** `/authorize` answers with a built-in, build-free page — the step where the
731
+ user decides whether an MCP client may act for them:
732
+
733
+ - **Nobody signed in** → the sign-in view: e-mail + password and the two-factor step (TOTP or backup
734
+ code) against Better-Auth's own endpoints on this origin (`<basePath>/sign-in/email`,
735
+ `/two-factor/verify-totp`, `/two-factor/verify-backup-code`), then a reload of the same `/authorize`
736
+ request. Passkey and social login go through the app's login page: `ai.mcp.oauthLoginUrl`, else
737
+ `<appUrl>/auth/login`, opened next to the consent page, which then offers "continue". A `{returnTo}`
738
+ in `oauthLoginUrl` is replaced by the pending request's URL, for a login page that sends the user
739
+ back by itself. A user already signed in to the API origin (session cookie) skips this view.
740
+ - **Signed in** → the consent view: the signed-in account first (with "use another account", which signs
741
+ out and shows the sign-in view), the client's name, the host the code goes to (a loopback host is
742
+ named "an application on this computer"), Allow / Deny as a form POST. Allow is rendered `disabled`;
743
+ a nonce'd script enables it, and a click counts only after intent on this page — visible, focused,
744
+ and a pointer move / key / touch at least 600 ms after it got there. That defeats
745
+ double-clickjacking (a hostile page sends this window to `/authorize` behind a popup and lets the
746
+ second click of a "double-click" land on Allow). Without JavaScript nothing can be approved, and the
747
+ sign-in forms are hidden and `method="post"`, so a password never lands in a URL.
748
+ - **Allow** issues the code only with the page's CSRF token, signed with the OAuth secret and bound to
749
+ the user, the client, the redirect URI and the PKCE challenge (10 minutes); a stale token shows the
750
+ form again, a forged or tampered one too, and nothing is issued. **Deny** sends the client back with
751
+ `access_denied`.
752
+
753
+ The page carries its own `Content-Security-Policy` (script and style by nonce, `frame-ancestors 'none'`,
754
+ no `form-action`, which browsers would apply to the redirect to the client), `X-Frame-Options: DENY`,
755
+ `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, independent of `security.headers`. Every
756
+ value from outside is escaped — the client's name is chosen by whoever registered the client. German
757
+ for a German `Accept-Language`, English otherwise.
758
+
759
+ Why Allow is a deliberate step: dynamic registration is open by design, so anybody can register a
760
+ client with their own redirect URI and start the flow — PKCE is then theirs too. A GET to `/authorize`
761
+ is what any link produces, and it carries the session cookie, so a page that issued on GET would let any
762
+ link a signed-in user opens authorize an attacker's client. The framework refuses a code saved while
763
+ answering a GET to `/authorize` (logged; the client gets `error=server_error`) — for the built-in page
764
+ and for one of your own alike.
765
+
766
+ **Global prefix.** Nest registers the Better-Auth middleware below a global prefix while the OAuth router sits at the
767
+ root, so the page resolves the session itself when the middleware did not run, and signs in at the prefixed IAM path
768
+ (`/api/iam/sign-in/email`). Better-Auth's own routes are not forwarded below a global prefix (see `setupSwagger()`), so
769
+ the two-factor step does not work there; users with 2FA sign in through the app.
770
+
771
+ **Needs Better-Auth.** The page signs users in through it; without Better-Auth active the boot warns and
772
+ the page offers no sign-in. Its sign-in goes through the IAM sign-in controller, which (since 11.43.0)
773
+ hands Better-Auth the request so Better-Auth's own login-CSRF protection runs — otherwise a hostile page
774
+ could sign the visitor in to the attacker's account before they approve a client.
775
+
776
+ **Your own page.** Override `renderConsentPage(view)` in a subclass to restyle it (keep every value
777
+ escaped), or `authorizeConsent(client, params, res)` for a consent UI of your own — same shape: no session
778
+ → sign in, GET → show which client, POST with a CSRF token → `saveAuthorizationCode()` and redirect;
779
+ refuse with `AccessDeniedError` from `this.loadOAuthErrors()`. Register the subclass via
780
+ `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`, so the MCP controller and the endpoints share it.
781
+ Two checks stay outside the overridable methods: an API token can never approve a consent, and the
782
+ production secret check runs again before the endpoints are mounted (without a signing secret, a
783
+ environment outside `local` / `ci` / `e2e` refuses to boot and to sign tokens at all — deployed `develop` and `test`
784
+ stages included).
785
+
786
+ Known property of the SDK: `/authorize` redirects its error answers to the client's registered
787
+ redirect URI, and any client may register any https URI — an open redirect in the sense of RFC 9700
788
+ §4.11.2, carrying only an OAuth error code.
789
+
790
+ **Refusals are OAuth errors (11.43.0).** The SDK's handlers answer an error with its OAuth code only
791
+ when it is one of the SDK's own `OAuthError` classes; anything else becomes `500 server_error`. The
792
+ built-in provider refuses an unknown, rotated or foreign refresh token or authorization code with
793
+ `400 invalid_grant` (until 11.43.0 these were plain `Error`s and answered 500). That matters beyond the
794
+ status: an MCP client discards its stored tokens only on `invalid_grant`, so on a 500 it kept a dead
795
+ token, retried, and never asked the user to sign in again. A refusal in your own `authorizeConsent()`
796
+ needs the same: take the class from `this.loadOAuthErrors()` (e.g. `AccessDeniedError` when the user
797
+ declines), never a plain `Error`.
798
+
714
799
  ## Tests
715
800
 
716
801
  - `tests/unit/ai.spec.ts` — crypto, registry role filtering, orchestrator loop,