@lenne.tech/nest-server 11.42.15 → 11.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/better-auth.md +22 -0
- package/.claude/rules/configurable-features.md +4 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +6 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
- package/dist/core/modules/ai/index.d.ts +1 -0
- package/dist/core/modules/ai/index.js +1 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
- package/dist/core/modules/better-auth/better-auth.config.js +29 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +30 -1
- package/docs/security-overrides.md +3 -2
- package/migration-guides/11.42.15-to-11.42.16.md +114 -0
- package/migration-guides/11.42.x-to-11.43.x.md +195 -0
- package/package.json +7 -7
- package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
- package/src/core/common/helpers/swagger.helper.ts +4 -4
- package/src/core/common/interfaces/server-options.interface.ts +59 -9
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
- package/src/core/modules/ai/README.md +98 -13
- package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
- package/src/core/modules/ai/core-ai.module.ts +14 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
- package/src/core/modules/ai/index.ts +1 -0
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
- package/src/core/modules/better-auth/better-auth.config.ts +49 -5
- package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
- package/src/core.module.ts +13 -1
- package/src/index.ts +1 -0
|
@@ -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 {
|
|
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 `
|
|
47
|
-
* `
|
|
48
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
147
|
+
/** Throw when OAuth is enabled but no secret is configured outside local / ci / e2e. */
|
|
76
148
|
assertProductionSafe(): void {
|
|
77
|
-
if (this.oauthEnabled() &&
|
|
149
|
+
if (this.oauthEnabled() && !this.allowsDevelopmentSecret() && !this.resolveSecret()) {
|
|
78
150
|
throw new Error(
|
|
79
|
-
'AI MCP OAuth signing secret is required
|
|
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
|
|
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
|
-
|
|
426
|
+
const { AccessDeniedError } = await this.loadOAuthErrors();
|
|
427
|
+
throw new AccessDeniedError('An API token cannot authorize an OAuth client');
|
|
322
428
|
}
|
|
323
|
-
|
|
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
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
+
const { InvalidTokenError } = await this.loadOAuthErrors();
|
|
486
|
+
throw new InvalidTokenError('Invalid or expired access token');
|
|
370
487
|
}
|
|
371
|
-
|
|
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
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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(
|
|
382
|
-
|
|
383
|
-
|
|
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 =
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|