@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
@@ -1,4 +1,5 @@
1
1
  // AI module public API
2
+ export * from './core-ai-mcp-oauth.initializer';
2
3
  export * from './core-ai-mcp.controller';
3
4
  export * from './helpers/ai-mcp-oauth.helper';
4
5
  export * from './core-ai.controller';
@@ -1,11 +1,23 @@
1
+ import type * as OAuthErrors from '@modelcontextprotocol/sdk/server/auth/errors.js';
1
2
  import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2
3
  import { InjectConnection } from '@nestjs/mongoose';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
5
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
4
6
  import { Connection } from 'mongoose';
5
7
 
6
- import { isProductionLikeEnv } from '../../../common/helpers/cookies.helper';
8
+ import { isLocalEnv, resolveAppUrlFromConfig } from '../../../common/helpers/cookies.helper';
7
9
  import { ConfigService } from '../../../common/services/config.service';
8
- import { getApiTokenContext } from '../../api-token/core-api-token.helpers';
10
+ import { getApiTokenContext, hasApiTokenCredential } from '../../api-token/core-api-token.helpers';
11
+ import { getBetterAuthTokenService } from '../../better-auth/core-better-auth.registry';
12
+ import {
13
+ IMcpOAuthConsentView,
14
+ MCP_OAUTH_CONSENT_DECISION_FIELD,
15
+ MCP_OAUTH_CONSENT_TOKEN_FIELD,
16
+ mcpOAuthConsentHeaders,
17
+ mcpOAuthConsentLocale,
18
+ renderMcpOAuthConsentPage,
19
+ } from '../helpers/ai-mcp-oauth-consent.helper';
20
+ import { getMountedMcpOAuth } from '../helpers/ai-mcp-oauth.registry';
9
21
 
10
22
  /**
11
23
  * Stored OAuth client (dynamically registered).
@@ -36,6 +48,65 @@ export interface AiMcpAccessTokenPayload {
36
48
  type: 'mcp_access';
37
49
  }
38
50
 
51
+ /**
52
+ * What a consent form's CSRF token is bound to: who approves, which client, where the code goes,
53
+ * and the PKCE challenge — so neither a token from another user's page nor a form with swapped
54
+ * parameters can issue a code.
55
+ */
56
+ export interface IAiMcpOAuthConsentBinding {
57
+ clientId: string;
58
+ codeChallenge: string;
59
+ redirectUri: string;
60
+ userId: string;
61
+ }
62
+
63
+ /**
64
+ * The HTTP method of the `/authorize` request a consent step is answering, while it answers it.
65
+ *
66
+ * Set by the provider's `authorize` (outside the overridable {@link CoreAiMcpOAuthService.authorizeConsent}),
67
+ * read by {@link assertCodeIssuanceAllowed}.
68
+ */
69
+ const authorizeRequest = new AsyncLocalStorage<{ method: string }>();
70
+
71
+ /**
72
+ * Refuses to issue an authorization code while answering a GET to `/authorize`.
73
+ *
74
+ * A GET is what a link, an image or a redirect produces, and it carries the user's session cookie
75
+ * (SameSite=Lax). Anybody may register a client with their own redirect URI and start the flow, so
76
+ * PKCE is theirs too. A consent that issues on GET therefore hands a code for whoever opens the
77
+ * attacker's link to the attacker, who redeems it for that user's MCP access. The code must come
78
+ * from a POST: the form in which the user approved this client. A code saved outside the
79
+ * `/authorize` handler (e.g. by a consent route of the project's own) is not affected; that route
80
+ * carries the same obligation.
81
+ */
82
+ function assertCodeIssuanceAllowed(): void {
83
+ const request = authorizeRequest.getStore();
84
+ if (request && request.method !== 'POST') {
85
+ const message =
86
+ `MCP OAuth: refused to issue an authorization code while answering ${request.method || 'a non-POST'} /authorize. ` +
87
+ 'Show the consent page on GET and issue the code only when the user approves it with a POST.';
88
+ // Logged here because the SDK turns the error into a bare `error=server_error` redirect and says
89
+ // nothing server-side: without this line a consent implemented the wrong way fails silently.
90
+ new Logger('CoreAiMcpOAuthService').error(message);
91
+ throw new Error(message);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * What a consent token signs. JSON, so no value can run into the next; the leading `[` keeps it
97
+ * apart from the access tokens' input (base64url, never a `[`), which use the same key.
98
+ */
99
+ function consentTokenPayload(binding: IAiMcpOAuthConsentBinding, exp: number): string {
100
+ return JSON.stringify([
101
+ 'mcp_consent',
102
+ binding.userId,
103
+ binding.clientId,
104
+ binding.redirectUri,
105
+ binding.codeChallenge,
106
+ exp,
107
+ ]);
108
+ }
109
+
39
110
  /**
40
111
  * Security core for the MCP OAuth 2.1 flow.
41
112
  *
@@ -43,9 +114,10 @@ export interface AiMcpAccessTokenPayload {
43
114
  * (constant-time verification), PKCE (S256) verification — and MongoDB-backed
44
115
  * stores for dynamically registered clients, authorization codes (TTL) and
45
116
  * refresh tokens. These power the SDK `OAuthServerProvider` returned by
46
- * {@link buildOAuthProvider}, which `mcpAuthRouter` mounts (see
47
- * `mountAiMcpOAuth`). The interactive consent/authorize flow is a thin layer on
48
- * top of these primitives and is documented in the integration checklist.
117
+ * {@link buildOAuthProvider}, which `CoreAiMcpOAuthInitializer` mounts as `mcpAuthRouter`
118
+ * when `ai.mcp.oauth` is enabled. The interactive consent step is built in
119
+ * ({@link authorizeConsent}: sign-in through Better-Auth, then Allow / Deny); a subclass
120
+ * registered via `CoreModule.forRoot(env, { ai: { mcpOAuthService } })` can restyle or replace it.
49
121
  *
50
122
  * Token format: `base64url(JSON payload).base64url(HMAC-SHA256)`.
51
123
  */
@@ -64,25 +136,48 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
64
136
  constructor(@InjectConnection() protected readonly connection: Connection) {}
65
137
 
66
138
  /**
67
- * Fail loud at boot in production/staging when the OAuth 2.1 layer is enabled
68
- * (`ai.mcp.oauth`) but no signing secret is configured — otherwise tokens would be
69
- * signed with a public, insecure development default.
139
+ * Fail loud at boot when the OAuth 2.1 layer is enabled (`ai.mcp.oauth`) but no signing secret
140
+ * is configured outside the local environments — otherwise tokens would be signed with a
141
+ * public, insecure development default (see {@link allowsDevelopmentSecret}).
70
142
  */
71
143
  onModuleInit(): void {
72
144
  this.assertProductionSafe();
73
145
  }
74
146
 
75
- /** Throw in production/staging when OAuth is enabled but no secret is configured. */
147
+ /** Throw when OAuth is enabled but no secret is configured outside local / ci / e2e. */
76
148
  assertProductionSafe(): void {
77
- if (this.oauthEnabled() && isProductionLikeEnv(ConfigService.get<string>('env')) && !this.resolveSecret()) {
149
+ if (this.oauthEnabled() && !this.allowsDevelopmentSecret() && !this.resolveSecret()) {
78
150
  throw new Error(
79
- 'AI MCP OAuth signing secret is required in production/staging when ai.mcp.oauth is enabled, ' +
151
+ 'AI MCP OAuth signing secret is required when ai.mcp.oauth is enabled outside local / ci / e2e, ' +
80
152
  'but none is set (ai.mcp.oauthSecret / ai.encryptionSecret / NSC__AI__ENCRYPTION_SECRET / ' +
81
153
  'SECRETS_ENCRYPTION_KEY). Set a random 32+ char value.',
82
154
  );
83
155
  }
84
156
  }
85
157
 
158
+ /**
159
+ * Whether the public development key may sign tokens: only in the local environments
160
+ * (`isLocalEnv`: `local`, `ci`, `e2e`). Deliberately not "everything except production / staging":
161
+ * a starter-based project DEPLOYS `develop` and `test` stages, and there the public key would let
162
+ * anybody who knows a user id forge that user's MCP access token.
163
+ */
164
+ protected allowsDevelopmentSecret(): boolean {
165
+ return isLocalEnv(ConfigService.get<string>('env') ?? process.env.NODE_ENV);
166
+ }
167
+
168
+ /** Whether the OAuth 2.1 layer is enabled — `CoreAiMcpOAuthInitializer` mounts the endpoints only then. */
169
+ isOAuthEnabled(): boolean {
170
+ return this.oauthEnabled();
171
+ }
172
+
173
+ /**
174
+ * Whether the built-in consent page answers `/authorize` (no subclass replaced {@link authorizeConsent}).
175
+ * It signs users in through Better-Auth, which `CoreAiMcpOAuthInitializer` checks at boot.
176
+ */
177
+ usesBuiltInConsent(): boolean {
178
+ return this.authorizeConsent === CoreAiMcpOAuthService.prototype.authorizeConsent;
179
+ }
180
+
86
181
  /** Whether the OAuth 2.1 layer is enabled via `ai.mcp.oauth`. */
87
182
  protected oauthEnabled(): boolean {
88
183
  const mcp = ConfigService.get<{ oauth?: boolean }>('ai.mcp');
@@ -206,11 +301,15 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
206
301
 
207
302
  /**
208
303
  * Persist an authorization code with its PKCE challenge (TTL 10 minutes).
304
+ *
305
+ * Issuing a code IS the authorization. While answering `/authorize` this refuses unless the
306
+ * request is a POST — the user's approval of the consent form (see `assertCodeIssuanceAllowed`).
209
307
  */
210
308
  async saveAuthorizationCode(
211
309
  code: string,
212
310
  data: { clientId: string; codeChallenge: string; userId: string },
213
311
  ): Promise<void> {
312
+ assertCodeIssuanceAllowed();
214
313
  await this.ensureIndexes();
215
314
  await this.db().collection(this.codesCollection).insertOne({
216
315
  clientId: data.clientId,
@@ -233,11 +332,17 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
233
332
 
234
333
  /**
235
334
  * Consume (read + delete) an authorization code. One-time use.
335
+ *
336
+ * With `clientId`, only a code issued to that client is consumed — atomically, like
337
+ * {@link rotateRefreshToken}. Another client presenting it then finds nothing and leaves the code
338
+ * for its owner, instead of deleting it first and comparing afterwards.
236
339
  */
237
340
  async consumeAuthorizationCode(
238
341
  code: string,
342
+ clientId?: string,
239
343
  ): Promise<{ clientId: string; codeChallenge: string; userId: string } | null> {
240
- const doc = await this.db().collection(this.codesCollection).findOneAndDelete({ code });
344
+ const filter = clientId === undefined ? { code } : { clientId, code };
345
+ const doc = await this.db().collection(this.codesCollection).findOneAndDelete(filter);
241
346
  const value = (doc as any)?.value ?? doc; // driver version compatibility
242
347
  if (!value) {
243
348
  return null;
@@ -318,9 +423,13 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
318
423
  // @ApiTokenScopes() does not reach it; the check sits here rather than in authorizeConsent()
319
424
  // so an override of that method cannot drop it.
320
425
  if (getApiTokenContext(res?.req?.user)) {
321
- throw new Error('access_denied: an API token cannot authorize an OAuth client');
426
+ const { AccessDeniedError } = await this.loadOAuthErrors();
427
+ throw new AccessDeniedError('An API token cannot authorize an OAuth client');
322
428
  }
323
- return this.authorizeConsent(client, params, res);
429
+ // Lets saveAuthorizationCode() refuse a code issued on GET — here, not in authorizeConsent(),
430
+ // so an override cannot drop it.
431
+ const method = String(res?.req?.method ?? '').toUpperCase();
432
+ return authorizeRequest.run({ method }, () => this.authorizeConsent(client, params, res));
324
433
  },
325
434
  challengeForAuthorizationCode: async (_client: any, authorizationCode: string) => {
326
435
  const stored = await this.getAuthorizationCode(authorizationCode);
@@ -331,9 +440,12 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
331
440
  registerClient: (client: AiMcpOAuthClient) => this.registerClient(client),
332
441
  },
333
442
  exchangeAuthorizationCode: async (client: any, authorizationCode: string) => {
334
- const stored = await this.consumeAuthorizationCode(authorizationCode);
335
- if (!stored || stored.clientId !== client.client_id) {
336
- throw new Error('invalid_grant');
443
+ const stored = await this.consumeAuthorizationCode(authorizationCode, String(client?.client_id ?? ''));
444
+ if (!stored || stored.clientId !== client?.client_id) {
445
+ const { InvalidGrantError } = await this.loadOAuthErrors();
446
+ throw new InvalidGrantError(
447
+ 'Authorization code is invalid, expired, already used or issued to another client',
448
+ );
337
449
  }
338
450
  return {
339
451
  access_token: this.signAccessToken(stored.userId, stored.clientId, accessTtlSeconds),
@@ -350,11 +462,15 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
350
462
  // via `findOneAndDelete({ token, clientId })`.
351
463
  const callerClientId = client?.client_id;
352
464
  if (!callerClientId) {
353
- throw new Error('invalid_client');
465
+ const { InvalidClientError } = await this.loadOAuthErrors();
466
+ throw new InvalidClientError('Unknown client');
354
467
  }
355
468
  const rotated = await this.rotateRefreshToken(refreshToken, callerClientId);
356
469
  if (!rotated) {
357
- throw new Error('invalid_grant');
470
+ // invalid_grant is the one answer on which an MCP client discards its stored tokens and asks
471
+ // the user to sign in again — see loadOAuthErrors().
472
+ const { InvalidGrantError } = await this.loadOAuthErrors();
473
+ throw new InvalidGrantError('Refresh token is invalid, already used or issued to another client');
358
474
  }
359
475
  return {
360
476
  access_token: this.signAccessToken(rotated.userId, rotated.clientId, accessTtlSeconds),
@@ -366,22 +482,253 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
366
482
  verifyAccessToken: async (token: string) => {
367
483
  const payload = this.verifyAccessToken(token);
368
484
  if (!payload) {
369
- throw new Error('invalid_token');
485
+ const { InvalidTokenError } = await this.loadOAuthErrors();
486
+ throw new InvalidTokenError('Invalid or expired access token');
370
487
  }
371
- return { clientId: payload.cid, extra: { userId: payload.sub }, scopes: [], token };
488
+ // `expiresAt` is part of the SDK's AuthInfo contract: its bearer middleware refuses a token without one.
489
+ return { clientId: payload.cid, expiresAt: payload.exp, extra: { userId: payload.sub }, scopes: [], token };
372
490
  },
373
491
  };
374
492
  }
375
493
 
494
+ /** Lifetime of a consent form's CSRF token, in seconds — the TTL of an authorization code. */
495
+ protected readonly consentTokenTtlSeconds = 600;
496
+
376
497
  /**
377
- * Interactive authorize/consent step. Override to integrate your login + consent
378
- * UI: authenticate the user, then call {@link saveAuthorizationCode} and redirect
379
- * to `redirect_uri?code=...&state=...`. Default: not implemented.
498
+ * The interactive authorize/consent step — built in since 11.43.0.
499
+ *
500
+ * - nobody signed in → the sign-in view: e-mail + password and the two-factor step against
501
+ * Better-Auth on this origin, then a reload of this very request; passkey / social login via the
502
+ * app's login page (`ai.mcp.oauthLoginUrl`, else `<appUrl>/auth/login`).
503
+ * - signed in, GET → the consent view: which client, where the code goes, Allow / Deny, with a
504
+ * CSRF token bound to user, client, redirect URI and PKCE challenge.
505
+ * - signed in, POST with Allow and a valid token → the code is issued, the browser goes to
506
+ * `redirect_uri?code=…&state=…`. Deny → `access_denied`. A stale token shows the form again; a
507
+ * foreign or tampered one too, and issues nothing.
508
+ *
509
+ * The signed-in user is whoever the Better-Auth middleware put on the request (session cookie or
510
+ * bearer). An API token never gets here — `buildOAuthProvider().authorize` refuses it first — and a
511
+ * code issued on GET is refused by `assertCodeIssuanceAllowed`.
512
+ *
513
+ * Override for a consent UI of your own (register the subclass via
514
+ * `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`), or override {@link renderConsentPage}
515
+ * to only restyle this one. An own implementation must keep the same shape — a signed-in session
516
+ * is not consent — and refuse with a class from {@link loadOAuthErrors}: any other error reaches
517
+ * the client as `server_error`.
380
518
  */
381
- protected async authorizeConsent(_client: any, _params: any, _res: any): Promise<void> {
382
- throw new Error(
383
- 'authorizeConsent is not implemented — override CoreAiMcpOAuthService.authorizeConsent to wire your login/consent UI.',
384
- );
519
+ protected async authorizeConsent(client: any, params: any, res: any): Promise<void> {
520
+ const req = res?.req;
521
+ const user = await this.consentUser(req);
522
+ const decision = req?.method === 'POST' ? req.body?.[MCP_OAUTH_CONSENT_DECISION_FIELD] : undefined;
523
+
524
+ if (user && decision === 'deny') {
525
+ const { AccessDeniedError } = await this.loadOAuthErrors();
526
+ throw new AccessDeniedError('The user denied access');
527
+ }
528
+
529
+ let notice: IMcpOAuthConsentView['notice'];
530
+ if (user && decision === 'approve') {
531
+ const binding: IAiMcpOAuthConsentBinding = {
532
+ clientId: String(client.client_id),
533
+ codeChallenge: String(params.codeChallenge),
534
+ redirectUri: String(params.redirectUri),
535
+ userId: user.id,
536
+ };
537
+ const verdict = this.verifyConsentToken(req.body?.[MCP_OAUTH_CONSENT_TOKEN_FIELD], binding);
538
+ if (verdict === 'valid') {
539
+ const code = this.generateId();
540
+ await this.saveAuthorizationCode(code, {
541
+ clientId: binding.clientId,
542
+ codeChallenge: binding.codeChallenge,
543
+ userId: binding.userId,
544
+ });
545
+ const target = new URL(binding.redirectUri);
546
+ target.searchParams.set('code', code);
547
+ if (params.state) {
548
+ target.searchParams.set('state', params.state);
549
+ }
550
+ res.redirect(302, target.href);
551
+ return;
552
+ }
553
+ if (verdict === 'expired') {
554
+ notice = 'expired';
555
+ } else {
556
+ this.logger.warn(
557
+ `MCP OAuth consent for client ${binding.clientId} carried an invalid token (forged, tampered, or from ` +
558
+ 'another session); no code issued, the form is shown again.',
559
+ );
560
+ }
561
+ }
562
+
563
+ const view = this.buildConsentView(req, client, params, user, notice);
564
+ res.status(200);
565
+ for (const [name, value] of Object.entries(mcpOAuthConsentHeaders(view.nonce))) {
566
+ res.setHeader(name, value);
567
+ }
568
+ res.send(this.renderConsentPage(view));
569
+ }
570
+
571
+ /** Renders the consent page. Override to restyle it; keep every dynamic value escaped. */
572
+ protected renderConsentPage(view: IMcpOAuthConsentView): string {
573
+ return renderMcpOAuthConsentPage(view);
574
+ }
575
+
576
+ /**
577
+ * The user the consent is given for: whoever the Better-Auth middleware put on the request — or,
578
+ * where it did not run, the session the request carries. Nest registers that middleware below a
579
+ * global prefix, while the OAuth router sits at the root, so under `setGlobalPrefix()` it never
580
+ * sees `/authorize`; the session is then resolved the way the middleware resolves it (bearer, then
581
+ * session cookie). Never an API token — those carry their own, narrower rights.
582
+ */
583
+ protected async consentUser(req: any): Promise<{ email?: string; id: string; name?: string } | undefined> {
584
+ const toConsentUser = (user: any) =>
585
+ user?.id && !getApiTokenContext(user) ? { email: user.email, id: String(user.id), name: user.name } : undefined;
586
+ if (req?.user?.id) {
587
+ return toConsentUser(req.user);
588
+ }
589
+ const tokenService = getBetterAuthTokenService();
590
+ if (!tokenService || !req || hasApiTokenCredential(req)) {
591
+ return undefined;
592
+ }
593
+ try {
594
+ const { token } = tokenService.extractTokenFromRequest(req);
595
+ return token ? toConsentUser(await tokenService.verifyAndLoadUser(token)) : undefined;
596
+ } catch {
597
+ return undefined;
598
+ }
599
+ }
600
+
601
+ /** Builds what the page shows. */
602
+ protected buildConsentView(
603
+ req: any,
604
+ client: any,
605
+ params: any,
606
+ user: { email?: string; id: string; name?: string } | undefined,
607
+ notice?: IMcpOAuthConsentView['notice'],
608
+ ): IMcpOAuthConsentView {
609
+ const redirect = new URL(String(params.redirectUri));
610
+ const oauthParams: Record<string, string> = {
611
+ client_id: String(client.client_id),
612
+ code_challenge: String(params.codeChallenge),
613
+ code_challenge_method: 'S256',
614
+ redirect_uri: String(params.redirectUri),
615
+ response_type: 'code',
616
+ };
617
+ if (params.state) {
618
+ oauthParams.state = String(params.state);
619
+ }
620
+ if (Array.isArray(params.scopes) && params.scopes.length) {
621
+ oauthParams.scope = params.scopes.join(' ');
622
+ }
623
+ if (params.resource) {
624
+ oauthParams.resource = String(params.resource.href ?? params.resource);
625
+ }
626
+ return {
627
+ appHost: this.consentAppHost(),
628
+ authBasePath: getBetterAuthTokenService()
629
+ ? getMountedMcpOAuth()?.authBasePath || ConfigService.get<string>('betterAuth.basePath') || '/iam'
630
+ : undefined,
631
+ client: { id: String(client.client_id), name: client.client_name },
632
+ csrfToken: user
633
+ ? this.signConsentToken({
634
+ clientId: oauthParams.client_id,
635
+ codeChallenge: oauthParams.code_challenge,
636
+ redirectUri: oauthParams.redirect_uri,
637
+ userId: user.id,
638
+ })
639
+ : undefined,
640
+ locale: mcpOAuthConsentLocale(req?.headers?.['accept-language']),
641
+ loginUrl: user ? undefined : this.consentLoginUrl(req),
642
+ nonce: randomBytes(16).toString('base64'),
643
+ notice,
644
+ oauthParams,
645
+ redirect: { host: redirect.host, loopback: ['localhost', '127.0.0.1', '[::1]'].includes(redirect.hostname) },
646
+ user: user ? { email: user.email, name: user.name } : undefined,
647
+ };
648
+ }
649
+
650
+ /** The host users know the application by: the app's, else the server's. */
651
+ protected consentAppHost(): string {
652
+ for (const url of [resolveAppUrlFromConfig(ConfigService), getMountedMcpOAuth()?.baseUrl]) {
653
+ try {
654
+ if (url) {
655
+ return new URL(url).host;
656
+ }
657
+ } catch {
658
+ // not a URL — try the next one
659
+ }
660
+ }
661
+ return '';
662
+ }
663
+
664
+ /**
665
+ * The app's login page, for sign-in methods the consent page does not offer (passkey, social
666
+ * login). `ai.mcp.oauthLoginUrl` wins; a `{returnTo}` in it is replaced by this `/authorize`
667
+ * request's URL, for a login page that sends the user back by itself. Default:
668
+ * `<appUrl>/auth/login`, opened next to the consent page.
669
+ */
670
+ protected consentLoginUrl(req: any): IMcpOAuthConsentView['loginUrl'] {
671
+ const configured = ConfigService.get<unknown>('ai.mcp.oauthLoginUrl');
672
+ const appUrl = resolveAppUrlFromConfig(ConfigService);
673
+ const template =
674
+ typeof configured === 'string' && configured.trim()
675
+ ? configured.trim()
676
+ : appUrl
677
+ ? `${appUrl.replace(/\/$/, '')}/auth/login`
678
+ : undefined;
679
+ if (!template) {
680
+ return undefined;
681
+ }
682
+ const returnsHere = template.includes('{returnTo}');
683
+ const base = getMountedMcpOAuth()?.baseUrl;
684
+ const returnTo = returnsHere && base && req?.originalUrl ? new URL(String(req.originalUrl), `${base}/`).href : '';
685
+ try {
686
+ const href = new URL(template.replace('{returnTo}', () => encodeURIComponent(returnTo))).href;
687
+ return /^https?:/.test(href) ? { href, returnsHere } : undefined;
688
+ } catch {
689
+ return undefined;
690
+ }
691
+ }
692
+
693
+ /** Signs a consent form's CSRF token: `<exp>.<hmac>` over the binding and the expiry. */
694
+ protected signConsentToken(binding: IAiMcpOAuthConsentBinding): string {
695
+ const exp = Math.floor(Date.now() / 1000) + this.consentTokenTtlSeconds;
696
+ return `${exp}.${this.hmac(consentTokenPayload(binding, exp))}`;
697
+ }
698
+
699
+ /**
700
+ * Checks a consent form's CSRF token against the binding of THIS request. The signature is
701
+ * checked first, so `expired` is only ever said of a token this server issued for this binding.
702
+ */
703
+ protected verifyConsentToken(token: unknown, binding: IAiMcpOAuthConsentBinding): 'expired' | 'invalid' | 'valid' {
704
+ if (typeof token !== 'string') {
705
+ return 'invalid';
706
+ }
707
+ const parts = token.split('.');
708
+ const exp = Number(parts[0]);
709
+ if (parts.length !== 2 || !Number.isSafeInteger(exp) || !parts[1]) {
710
+ return 'invalid';
711
+ }
712
+ if (!this.safeEqual(parts[1], this.hmac(consentTokenPayload(binding, exp)))) {
713
+ return 'invalid';
714
+ }
715
+ return exp > Math.floor(Date.now() / 1000) ? 'valid' : 'expired';
716
+ }
717
+
718
+ /**
719
+ * The SDK's OAuth error classes, for every refusal the provider reports.
720
+ *
721
+ * The SDK's handlers decide the HTTP answer with `instanceof OAuthError`; anything else becomes
722
+ * `500 server_error`. That is more than a wrong status: an MCP client discards its stored tokens
723
+ * only on `invalid_grant`. A refused refresh token answered with a 500 made it keep the dead
724
+ * token, retry the refresh, and never ask the user to sign in again.
725
+ *
726
+ * Imported lazily, like every other use of the SDK in this module and the same way
727
+ * `mountAiMcpOAuth()` imports the router, so both resolve the same build of the SDK: a class
728
+ * from its other build (CJS vs. ESM) would fail that `instanceof` just like a plain `Error`.
729
+ */
730
+ protected loadOAuthErrors(): Promise<typeof OAuthErrors> {
731
+ return import('@modelcontextprotocol/sdk/server/auth/errors.js');
385
732
  }
386
733
 
387
734
  // ===================================================================================================================
@@ -413,6 +760,16 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
413
760
  */
414
761
  protected getSecret(): string {
415
762
  const raw = this.resolveSecret();
763
+ if (!raw && !this.allowsDevelopmentSecret()) {
764
+ // The boot check (assertProductionSafe) normally stops this earlier. This is the backstop for
765
+ // a path that skipped it, e.g. a subclass whose onModuleInit() does not call super: signing
766
+ // with the public default below would make every token forgeable.
767
+ throw new Error(
768
+ 'AI MCP OAuth signing secret is not configured outside local / ci / e2e; refusing to sign or verify ' +
769
+ 'tokens with the public development default. Set ai.mcp.oauthSecret (or ai.encryptionSecret / ' +
770
+ 'NSC__AI__ENCRYPTION_SECRET / SECRETS_ENCRYPTION_KEY).',
771
+ );
772
+ }
416
773
  if (!raw) {
417
774
  if (!this.warnedSecret) {
418
775
  this.logger.warn(
@@ -10,6 +10,7 @@ import * as path from 'path';
10
10
  import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
11
11
  import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
12
12
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
13
+ import { ConfigService } from '../../common/services/config.service';
13
14
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
14
15
  import { projectProvidesOpenApiPlugin } from './core-better-auth-openapi.helper';
15
16
 
@@ -482,11 +483,14 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
482
483
  // Build configuration components (pass normalized passkey config and resolved URLs)
483
484
  const plugins = buildPlugins(config, { passkeyNormalization, serverEnv });
484
485
  const socialProviders = buildSocialProviders(config);
485
- const trustedOrigins = buildTrustedOrigins(config, {
486
- passkeyNormalization,
487
- resolvedUrls,
488
- serverCorsConfig: options.serverCorsConfig,
489
- });
486
+ const trustedOrigins = withMcpOAuthIssuerOrigin(
487
+ buildTrustedOrigins(config, {
488
+ passkeyNormalization,
489
+ resolvedUrls,
490
+ serverCorsConfig: options.serverCorsConfig,
491
+ }),
492
+ mcpOAuthIssuerOrigin(ConfigService.configFastButReadOnly),
493
+ );
490
494
  const additionalFields = buildUserFields(config);
491
495
 
492
496
  // Resolve cross-subdomain cookies (Boolean Shorthand)
@@ -1122,6 +1126,46 @@ export function buildTrustedOrigins(
1122
1126
  return undefined;
1123
1127
  }
1124
1128
 
1129
+ /**
1130
+ * The origin MCP clients reach this server under when `ai.mcp.oauthBaseUrl` names another host than
1131
+ * `baseUrl`: the built-in MCP OAuth consent page is served there and signs users in from there. Only
1132
+ * with `ai.mcp.oauth: true`; anything but an absolute http(s) URL yields nothing.
1133
+ */
1134
+ export function mcpOAuthIssuerOrigin(serverConfig: { ai?: unknown } | undefined): string | undefined {
1135
+ const ai = serverConfig?.ai;
1136
+ const mcp = typeof ai === 'object' && ai !== null ? (ai as { mcp?: unknown }).mcp : undefined;
1137
+ if (typeof mcp !== 'object' || mcp === null) {
1138
+ return undefined;
1139
+ }
1140
+ const { oauth, oauthBaseUrl } = mcp as { oauth?: unknown; oauthBaseUrl?: unknown };
1141
+ if (oauth !== true || typeof oauthBaseUrl !== 'string' || !oauthBaseUrl.trim()) {
1142
+ return undefined;
1143
+ }
1144
+ try {
1145
+ const url = new URL(oauthBaseUrl.trim());
1146
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : undefined;
1147
+ } catch {
1148
+ return undefined;
1149
+ }
1150
+ }
1151
+
1152
+ /**
1153
+ * Adds the MCP OAuth issuer origin to Better-Auth's trusted origins — to an explicit
1154
+ * `betterAuth.trustedOrigins` as well, which otherwise REPLACES the derived list. The issuer is this
1155
+ * server under its second public URL, not a foreign frontend; without it Better-Auth's login-CSRF
1156
+ * check refuses the consent page's sign-in with 403. A list Better-Auth would leave unset (it then
1157
+ * trusts only its own baseURL) becomes the issuer alone — Better-Auth keeps trusting its baseURL.
1158
+ */
1159
+ export function withMcpOAuthIssuerOrigin(
1160
+ trustedOrigins: string[] | undefined,
1161
+ issuerOrigin: string | undefined,
1162
+ ): string[] | undefined {
1163
+ if (!issuerOrigin) {
1164
+ return trustedOrigins;
1165
+ }
1166
+ return [...new Set([...(trustedOrigins ?? []), issuerOrigin])];
1167
+ }
1168
+
1125
1169
  /**
1126
1170
  * Server-managed, security-critical user fields whose `input: false` MUST be re-asserted after
1127
1171
  * merging project-supplied `additionalUserFields`, so a project override cannot silently re-open