@dxheroes/local-mcp-backend 0.9.2 → 0.10.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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +27 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +246 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
- package/dist/__tests__/integration/proxy-auth.test.js +121 -111
- package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
- package/dist/__tests__/unit/auth.guard.test.js +23 -2
- package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
- package/dist/common/filters/all-exceptions.filter.js +6 -0
- package/dist/common/filters/all-exceptions.filter.js.map +1 -1
- package/dist/main.js +37 -0
- package/dist/main.js.map +1 -1
- package/dist/modules/auth/auth.config.js +5 -2
- package/dist/modules/auth/auth.config.js.map +1 -1
- package/dist/modules/auth/auth.module.js +5 -2
- package/dist/modules/auth/auth.module.js.map +1 -1
- package/dist/modules/auth/auth.service.js +2 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/mcp-oauth.guard.js +70 -0
- package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
- package/dist/modules/auth/mcp-oauth.utils.js +75 -0
- package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
- package/dist/modules/mcp/mcp.service.js +48 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/oauth/oauth.controller.js +78 -1
- package/dist/modules/oauth/oauth.controller.js.map +1 -1
- package/dist/modules/oauth/oauth.service.js +197 -1
- package/dist/modules/oauth/oauth.service.js.map +1 -1
- package/dist/modules/proxy/proxy.controller.js +152 -27
- package/dist/modules/proxy/proxy.controller.js.map +1 -1
- package/dist/modules/proxy/proxy.service.js +28 -4
- package/dist/modules/proxy/proxy.service.js.map +1 -1
- package/docker-entrypoint.sh +15 -2
- package/package.json +7 -7
- package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +281 -0
- package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
- package/src/__tests__/integration/proxy-auth.test.ts +119 -168
- package/src/__tests__/unit/auth.guard.test.ts +12 -2
- package/src/common/filters/all-exceptions.filter.ts +11 -0
- package/src/main.ts +32 -1
- package/src/modules/auth/auth.config.ts +4 -1
- package/src/modules/auth/auth.module.ts +3 -2
- package/src/modules/auth/auth.service.ts +2 -2
- package/src/modules/auth/mcp-oauth.guard.ts +75 -0
- package/src/modules/auth/mcp-oauth.utils.ts +80 -0
- package/src/modules/mcp/mcp.service.ts +54 -12
- package/src/modules/oauth/oauth.controller.ts +84 -1
- package/src/modules/oauth/oauth.service.ts +218 -1
- package/src/modules/proxy/proxy.controller.ts +120 -25
- package/src/modules/proxy/proxy.service.ts +26 -4
- package/vitest.config.ts +2 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ConfigService } from '@nestjs/config';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BACKEND_PORT = '3001';
|
|
4
|
+
const DEFAULT_FRONTEND_PORT = '3000';
|
|
5
|
+
|
|
6
|
+
function trimTrailingSlash(value: string): string {
|
|
7
|
+
return value.replace(/\/+$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function swapPort(origin: string, fromPort: string, toPort: string): string {
|
|
11
|
+
const url = new URL(origin);
|
|
12
|
+
if (url.port === fromPort) {
|
|
13
|
+
url.port = toPort;
|
|
14
|
+
}
|
|
15
|
+
return trimTrailingSlash(url.origin);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolvePublicBackendOrigin(configService: ConfigService): string {
|
|
19
|
+
const configuredUrl = configService.get<string>('BETTER_AUTH_URL');
|
|
20
|
+
if (configuredUrl) {
|
|
21
|
+
return trimTrailingSlash(new URL(configuredUrl).origin);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const port = configService.get<number>('app.port') || Number(DEFAULT_BACKEND_PORT);
|
|
25
|
+
return `http://localhost:${port}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolvePublicAuthBaseUrl(configService: ConfigService): string {
|
|
29
|
+
return `${resolvePublicBackendOrigin(configService)}/api/auth`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveFrontendOrigin(configService: ConfigService): string {
|
|
33
|
+
const configuredUrl = configService.get<string>('FRONTEND_URL');
|
|
34
|
+
if (configuredUrl) {
|
|
35
|
+
return trimTrailingSlash(new URL(configuredUrl).origin);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
39
|
+
const backendUrl = new URL(backendOrigin);
|
|
40
|
+
|
|
41
|
+
if (backendUrl.port === '9631') {
|
|
42
|
+
return swapPort(backendOrigin, '9631', '9630');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (backendUrl.port === DEFAULT_BACKEND_PORT) {
|
|
46
|
+
return swapPort(backendOrigin, DEFAULT_BACKEND_PORT, DEFAULT_FRONTEND_PORT);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return trimTrailingSlash(backendUrl.origin);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveMcpLoginPageUrl(configService: ConfigService): string {
|
|
53
|
+
return `${resolveFrontendOrigin(configService)}/sign-in`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createMcpProtectedResourceMetadata(configService: ConfigService) {
|
|
57
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
58
|
+
const authBaseUrl = resolvePublicAuthBaseUrl(configService);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
resource: `${backendOrigin}/api/mcp`,
|
|
62
|
+
authorization_servers: [backendOrigin],
|
|
63
|
+
bearer_methods_supported: ['header'],
|
|
64
|
+
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
|
|
65
|
+
jwks_uri: `${authBaseUrl}/mcp/jwks`,
|
|
66
|
+
resource_signing_alg_values_supported: ['RS256', 'none'],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createMcpWwwAuthenticateHeader(configService: ConfigService): string {
|
|
71
|
+
const resourceMetadataUrl = `${resolvePublicBackendOrigin(
|
|
72
|
+
configService
|
|
73
|
+
)}/.well-known/oauth-protected-resource`;
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
'Bearer',
|
|
77
|
+
`resource_metadata="${resourceMetadataUrl}"`,
|
|
78
|
+
`resource_metadata_uri="${resourceMetadataUrl}"`,
|
|
79
|
+
].join(' ');
|
|
80
|
+
}
|
|
@@ -254,11 +254,11 @@ export class McpService {
|
|
|
254
254
|
|
|
255
255
|
// For remote_http servers, connect and fetch tools
|
|
256
256
|
if (server.type === 'remote_http') {
|
|
257
|
-
const config = this.parseConfig(server.config) as { url: string };
|
|
257
|
+
const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
|
|
258
258
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
|
|
259
259
|
|
|
260
260
|
const remoteServer = new RemoteHttpMcpServer(
|
|
261
|
-
{ url: config.url, transport: 'http' },
|
|
261
|
+
{ url: config.url, transport: 'http', headers: config.headers },
|
|
262
262
|
null,
|
|
263
263
|
apiKeyConfig
|
|
264
264
|
);
|
|
@@ -269,11 +269,11 @@ export class McpService {
|
|
|
269
269
|
|
|
270
270
|
// For remote_sse servers, connect and fetch tools
|
|
271
271
|
if (server.type === 'remote_sse') {
|
|
272
|
-
const config = this.parseConfig(server.config) as { url: string };
|
|
272
|
+
const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
|
|
273
273
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
|
|
274
274
|
|
|
275
275
|
const remoteServer = new RemoteSseMcpServer(
|
|
276
|
-
{ url: config.url, transport: 'sse' },
|
|
276
|
+
{ url: config.url, transport: 'sse', headers: config.headers },
|
|
277
277
|
null,
|
|
278
278
|
apiKeyConfig
|
|
279
279
|
);
|
|
@@ -410,14 +410,30 @@ export class McpService {
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
// For remote_http servers, validate by connecting
|
|
413
|
+
let oauthRequired = false;
|
|
413
414
|
if (server.type === 'remote_http' && status === 'unknown') {
|
|
414
|
-
const config = this.parseConfig(server.config) as { url: string };
|
|
415
|
+
const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
|
|
415
416
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
|
|
416
417
|
|
|
418
|
+
// Get OAuth token if available, mapping Prisma types to core OAuthToken
|
|
419
|
+
const oauthToken = server.oauthToken
|
|
420
|
+
? {
|
|
421
|
+
id: server.oauthToken.id,
|
|
422
|
+
mcpServerId: server.oauthToken.mcpServerId,
|
|
423
|
+
accessToken: server.oauthToken.accessToken,
|
|
424
|
+
tokenType: server.oauthToken.tokenType,
|
|
425
|
+
refreshToken: server.oauthToken.refreshToken ?? undefined,
|
|
426
|
+
scope: server.oauthToken.scope ?? undefined,
|
|
427
|
+
expiresAt: server.oauthToken.expiresAt?.getTime(),
|
|
428
|
+
createdAt: server.oauthToken.createdAt.getTime(),
|
|
429
|
+
updatedAt: server.oauthToken.updatedAt.getTime(),
|
|
430
|
+
}
|
|
431
|
+
: null;
|
|
432
|
+
|
|
417
433
|
try {
|
|
418
434
|
const remoteServer = new RemoteHttpMcpServer(
|
|
419
|
-
{ url: config.url, transport: 'http' },
|
|
420
|
-
|
|
435
|
+
{ url: config.url, transport: 'http', headers: config.headers },
|
|
436
|
+
oauthToken,
|
|
421
437
|
apiKeyConfig
|
|
422
438
|
);
|
|
423
439
|
await remoteServer.initialize();
|
|
@@ -427,19 +443,39 @@ export class McpService {
|
|
|
427
443
|
} catch (error) {
|
|
428
444
|
status = 'error';
|
|
429
445
|
validationError = error instanceof Error ? error.message : 'Unknown error';
|
|
430
|
-
|
|
446
|
+
if (validationError.includes('OAUTH_REQUIRED')) {
|
|
447
|
+
oauthRequired = true;
|
|
448
|
+
validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
|
|
449
|
+
} else {
|
|
450
|
+
validationDetails = `Connection failed: ${validationError}`;
|
|
451
|
+
}
|
|
431
452
|
}
|
|
432
453
|
}
|
|
433
454
|
|
|
434
455
|
// For remote_sse servers, validate by connecting
|
|
435
456
|
if (server.type === 'remote_sse' && status === 'unknown') {
|
|
436
|
-
const config = this.parseConfig(server.config) as { url: string };
|
|
457
|
+
const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
|
|
437
458
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
|
|
438
459
|
|
|
460
|
+
// Get OAuth token if available, mapping Prisma types to core OAuthToken
|
|
461
|
+
const oauthToken = server.oauthToken
|
|
462
|
+
? {
|
|
463
|
+
id: server.oauthToken.id,
|
|
464
|
+
mcpServerId: server.oauthToken.mcpServerId,
|
|
465
|
+
accessToken: server.oauthToken.accessToken,
|
|
466
|
+
tokenType: server.oauthToken.tokenType,
|
|
467
|
+
refreshToken: server.oauthToken.refreshToken ?? undefined,
|
|
468
|
+
scope: server.oauthToken.scope ?? undefined,
|
|
469
|
+
expiresAt: server.oauthToken.expiresAt?.getTime(),
|
|
470
|
+
createdAt: server.oauthToken.createdAt.getTime(),
|
|
471
|
+
updatedAt: server.oauthToken.updatedAt.getTime(),
|
|
472
|
+
}
|
|
473
|
+
: null;
|
|
474
|
+
|
|
439
475
|
try {
|
|
440
476
|
const remoteServer = new RemoteSseMcpServer(
|
|
441
|
-
{ url: config.url, transport: 'sse' },
|
|
442
|
-
|
|
477
|
+
{ url: config.url, transport: 'sse', headers: config.headers },
|
|
478
|
+
oauthToken,
|
|
443
479
|
apiKeyConfig
|
|
444
480
|
);
|
|
445
481
|
await remoteServer.initialize();
|
|
@@ -449,7 +485,12 @@ export class McpService {
|
|
|
449
485
|
} catch (error) {
|
|
450
486
|
status = 'error';
|
|
451
487
|
validationError = error instanceof Error ? error.message : 'Unknown error';
|
|
452
|
-
|
|
488
|
+
if (validationError.includes('OAUTH_REQUIRED')) {
|
|
489
|
+
oauthRequired = true;
|
|
490
|
+
validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
|
|
491
|
+
} else {
|
|
492
|
+
validationDetails = `Connection failed: ${validationError}`;
|
|
493
|
+
}
|
|
453
494
|
}
|
|
454
495
|
}
|
|
455
496
|
|
|
@@ -493,6 +534,7 @@ export class McpService {
|
|
|
493
534
|
hasOAuth,
|
|
494
535
|
requiresApiKey,
|
|
495
536
|
requiresOAuth,
|
|
537
|
+
oauthRequired,
|
|
496
538
|
isReady,
|
|
497
539
|
status,
|
|
498
540
|
error: validationError,
|
|
@@ -4,7 +4,21 @@
|
|
|
4
4
|
* REST API endpoints for OAuth token management.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
Body,
|
|
9
|
+
Controller,
|
|
10
|
+
Delete,
|
|
11
|
+
Get,
|
|
12
|
+
HttpCode,
|
|
13
|
+
HttpStatus,
|
|
14
|
+
Logger,
|
|
15
|
+
Param,
|
|
16
|
+
Post,
|
|
17
|
+
Query,
|
|
18
|
+
Req,
|
|
19
|
+
Res,
|
|
20
|
+
} from '@nestjs/common';
|
|
21
|
+
import type { Request, Response } from 'express';
|
|
8
22
|
import { SkipOrgCheck } from '../auth/decorators/skip-org-check.decorator.js';
|
|
9
23
|
import { OAuthService } from './oauth.service.js';
|
|
10
24
|
|
|
@@ -36,8 +50,77 @@ interface StoreTokenDto {
|
|
|
36
50
|
@SkipOrgCheck()
|
|
37
51
|
@Controller('oauth')
|
|
38
52
|
export class OAuthController {
|
|
53
|
+
private readonly logger = new Logger(OAuthController.name);
|
|
54
|
+
|
|
39
55
|
constructor(private readonly oauthService: OAuthService) {}
|
|
40
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Start OAuth auto-discovery flow for an MCP server.
|
|
59
|
+
* Discovers OAuth endpoints via RFC 9728/8414, performs DCR if needed,
|
|
60
|
+
* and redirects the browser to the authorization server.
|
|
61
|
+
*/
|
|
62
|
+
@Get('authorize/:serverId')
|
|
63
|
+
async authorize(
|
|
64
|
+
@Param('serverId') serverId: string,
|
|
65
|
+
@Req() req: Request,
|
|
66
|
+
@Res() res: Response
|
|
67
|
+
) {
|
|
68
|
+
try {
|
|
69
|
+
const callbackUrl = `${req.protocol}://${req.get('host')}/api/oauth/callback`;
|
|
70
|
+
const authorizationUrl = await this.oauthService.discoverAndAuthorize(serverId, callbackUrl);
|
|
71
|
+
res.redirect(authorizationUrl);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
74
|
+
this.logger.error(`OAuth authorize failed for ${serverId}: ${message}`);
|
|
75
|
+
res.status(400).send(this.renderCallbackPage(false, message));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* OAuth callback endpoint. Receives authorization code, exchanges for tokens,
|
|
81
|
+
* and returns an HTML page that notifies the opener window.
|
|
82
|
+
*/
|
|
83
|
+
@Get('callback')
|
|
84
|
+
async callback(
|
|
85
|
+
@Query('code') code: string,
|
|
86
|
+
@Query('state') state: string,
|
|
87
|
+
@Req() req: Request,
|
|
88
|
+
@Res() res: Response
|
|
89
|
+
) {
|
|
90
|
+
if (!code || !state) {
|
|
91
|
+
res.status(400).send(this.renderCallbackPage(false, 'Missing code or state parameter'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const serverId = state;
|
|
96
|
+
const callbackUrl = `${req.protocol}://${req.get('host')}/api/oauth/callback`;
|
|
97
|
+
|
|
98
|
+
const result = await this.oauthService.handleCallback(serverId, code, callbackUrl);
|
|
99
|
+
res.status(200).send(this.renderCallbackPage(result.success, result.error));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render an HTML page that posts a message to the opener window and closes itself.
|
|
104
|
+
*/
|
|
105
|
+
private renderCallbackPage(success: boolean, error?: string): string {
|
|
106
|
+
const message = JSON.stringify({
|
|
107
|
+
type: 'oauth-callback',
|
|
108
|
+
success,
|
|
109
|
+
error: error || null,
|
|
110
|
+
});
|
|
111
|
+
return `<!DOCTYPE html>
|
|
112
|
+
<html><head><title>OAuth ${success ? 'Success' : 'Error'}</title></head>
|
|
113
|
+
<body>
|
|
114
|
+
<p>${success ? 'Authorization successful! This window will close.' : `Error: ${error}`}</p>
|
|
115
|
+
<script>
|
|
116
|
+
if (window.opener) {
|
|
117
|
+
window.opener.postMessage(${message}, '*');
|
|
118
|
+
}
|
|
119
|
+
setTimeout(function() { window.close(); }, 2000);
|
|
120
|
+
</script>
|
|
121
|
+
</body></html>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
41
124
|
/**
|
|
42
125
|
* Start OAuth flow for an MCP server
|
|
43
126
|
*/
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Manages OAuth tokens for MCP servers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { OAuthDiscoveryService } from '@dxheroes/local-mcp-core';
|
|
8
|
+
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
8
9
|
import { PrismaService } from '../database/prisma.service.js';
|
|
9
10
|
|
|
10
11
|
interface StartOAuthFlowDto {
|
|
@@ -35,8 +36,10 @@ interface StoreTokenDto {
|
|
|
35
36
|
|
|
36
37
|
@Injectable()
|
|
37
38
|
export class OAuthService {
|
|
39
|
+
private readonly logger = new Logger(OAuthService.name);
|
|
38
40
|
// In-memory storage for PKCE verifiers (in production, use Redis or similar)
|
|
39
41
|
private pkceVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
|
|
42
|
+
private readonly discoveryService = new OAuthDiscoveryService();
|
|
40
43
|
|
|
41
44
|
constructor(private readonly prisma: PrismaService) {}
|
|
42
45
|
|
|
@@ -183,6 +186,220 @@ export class OAuthService {
|
|
|
183
186
|
});
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Discover OAuth configuration and build an authorization URL for a server.
|
|
191
|
+
* Uses RFC 9728 / 8414 / 7591 auto-discovery and optional DCR.
|
|
192
|
+
* Returns an authorization URL the browser should be redirected to.
|
|
193
|
+
*/
|
|
194
|
+
async discoverAndAuthorize(serverId: string, callbackUrl: string): Promise<string> {
|
|
195
|
+
const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
|
|
196
|
+
if (!server) {
|
|
197
|
+
throw new NotFoundException(`MCP server ${serverId} not found`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parse server URL from config
|
|
201
|
+
const config = typeof server.config === 'string' ? JSON.parse(server.config) : server.config;
|
|
202
|
+
const serverUrl = config?.url as string | undefined;
|
|
203
|
+
if (!serverUrl) {
|
|
204
|
+
throw new BadRequestException('Server has no URL configured');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse existing oauthConfig if any (may have manual clientId)
|
|
208
|
+
const existingOAuthConfig = server.oauthConfig
|
|
209
|
+
? typeof server.oauthConfig === 'string'
|
|
210
|
+
? JSON.parse(server.oauthConfig)
|
|
211
|
+
: server.oauthConfig
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
// Step 1: Discover OAuth endpoints via RFC 9728 + 8414
|
|
215
|
+
this.logger.log(`Discovering OAuth for server ${serverId} at ${serverUrl}`);
|
|
216
|
+
const discovery = await this.discoveryService.discoverFromServerUrl(serverUrl);
|
|
217
|
+
|
|
218
|
+
// Step 2: Get or register client
|
|
219
|
+
let clientId: string;
|
|
220
|
+
|
|
221
|
+
if (existingOAuthConfig?.clientId) {
|
|
222
|
+
// Use manually configured clientId
|
|
223
|
+
clientId = existingOAuthConfig.clientId;
|
|
224
|
+
} else {
|
|
225
|
+
// Check for existing DCR registration
|
|
226
|
+
const existingReg = await this.prisma.oAuthClientRegistration.findUnique({
|
|
227
|
+
where: {
|
|
228
|
+
mcpServerId_authorizationServerUrl: {
|
|
229
|
+
mcpServerId: serverId,
|
|
230
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (existingReg) {
|
|
236
|
+
// Delete stale registration — redirect_uri may have changed
|
|
237
|
+
await this.prisma.oAuthClientRegistration.delete({
|
|
238
|
+
where: { id: existingReg.id },
|
|
239
|
+
});
|
|
240
|
+
this.logger.log(`Deleted stale DCR registration for ${serverId}, will re-register`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (discovery.registrationEndpoint) {
|
|
244
|
+
// Perform Dynamic Client Registration (RFC 7591)
|
|
245
|
+
this.logger.log(`Performing DCR at ${discovery.registrationEndpoint}`);
|
|
246
|
+
const registration = await this.discoveryService.registerClient(
|
|
247
|
+
discovery.registrationEndpoint,
|
|
248
|
+
callbackUrl,
|
|
249
|
+
discovery.scopes
|
|
250
|
+
);
|
|
251
|
+
clientId = registration.clientId;
|
|
252
|
+
|
|
253
|
+
// Store registration
|
|
254
|
+
await this.prisma.oAuthClientRegistration.create({
|
|
255
|
+
data: {
|
|
256
|
+
mcpServerId: serverId,
|
|
257
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
258
|
+
clientId: registration.clientId,
|
|
259
|
+
clientSecret: registration.clientSecret,
|
|
260
|
+
registrationAccessToken: registration.registrationAccessToken,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
throw new BadRequestException(
|
|
265
|
+
'No clientId configured and server does not support Dynamic Client Registration. ' +
|
|
266
|
+
'Please configure a clientId manually in the OAuth settings.'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Step 3: Store discovery result in oauthConfig on the server
|
|
272
|
+
await this.prisma.mcpServer.update({
|
|
273
|
+
where: { id: serverId },
|
|
274
|
+
data: {
|
|
275
|
+
oauthConfig: JSON.stringify({
|
|
276
|
+
authorizationServerUrl: discovery.authorizationServerUrl,
|
|
277
|
+
authorizationEndpoint: discovery.authorizationEndpoint,
|
|
278
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
279
|
+
registrationEndpoint: discovery.registrationEndpoint,
|
|
280
|
+
resource: discovery.resource,
|
|
281
|
+
scopes: discovery.scopes,
|
|
282
|
+
clientId,
|
|
283
|
+
requiresOAuth: true,
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Step 4: Generate PKCE
|
|
289
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
290
|
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
|
291
|
+
|
|
292
|
+
// Store verifier (expires in 10 minutes)
|
|
293
|
+
this.pkceVerifiers.set(serverId, {
|
|
294
|
+
verifier: codeVerifier,
|
|
295
|
+
expiresAt: Date.now() + 10 * 60 * 1000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Step 5: Build authorization URL
|
|
299
|
+
const params = new URLSearchParams({
|
|
300
|
+
response_type: 'code',
|
|
301
|
+
client_id: clientId,
|
|
302
|
+
redirect_uri: callbackUrl,
|
|
303
|
+
code_challenge: codeChallenge,
|
|
304
|
+
code_challenge_method: 'S256',
|
|
305
|
+
state: serverId, // Use serverId as state for callback routing
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (discovery.scopes.length > 0) {
|
|
309
|
+
params.set('scope', discovery.scopes.join(' '));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (discovery.resource) {
|
|
313
|
+
params.set('resource', discovery.resource);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return `${discovery.authorizationEndpoint}?${params.toString()}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handle OAuth callback: exchange authorization code for tokens.
|
|
321
|
+
*/
|
|
322
|
+
async handleCallback(
|
|
323
|
+
serverId: string,
|
|
324
|
+
code: string,
|
|
325
|
+
callbackUrl: string
|
|
326
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
327
|
+
const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
|
|
328
|
+
if (!server) {
|
|
329
|
+
return { success: false, error: `MCP server ${serverId} not found` };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Retrieve PKCE verifier
|
|
333
|
+
const pkceEntry = this.pkceVerifiers.get(serverId);
|
|
334
|
+
if (!pkceEntry) {
|
|
335
|
+
return { success: false, error: 'PKCE verifier not found or expired' };
|
|
336
|
+
}
|
|
337
|
+
if (pkceEntry.expiresAt < Date.now()) {
|
|
338
|
+
this.pkceVerifiers.delete(serverId);
|
|
339
|
+
return { success: false, error: 'PKCE verifier expired' };
|
|
340
|
+
}
|
|
341
|
+
const codeVerifier = pkceEntry.verifier;
|
|
342
|
+
this.pkceVerifiers.delete(serverId);
|
|
343
|
+
|
|
344
|
+
// Get oauthConfig for token endpoint and clientId
|
|
345
|
+
const oauthConfig = server.oauthConfig
|
|
346
|
+
? typeof server.oauthConfig === 'string'
|
|
347
|
+
? JSON.parse(server.oauthConfig)
|
|
348
|
+
: server.oauthConfig
|
|
349
|
+
: null;
|
|
350
|
+
|
|
351
|
+
if (!oauthConfig?.tokenEndpoint || !oauthConfig?.clientId) {
|
|
352
|
+
return { success: false, error: 'OAuth configuration incomplete (missing tokenEndpoint or clientId)' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Exchange code for tokens
|
|
357
|
+
const response = await fetch(oauthConfig.tokenEndpoint, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
360
|
+
body: new URLSearchParams({
|
|
361
|
+
grant_type: 'authorization_code',
|
|
362
|
+
code,
|
|
363
|
+
redirect_uri: callbackUrl,
|
|
364
|
+
client_id: oauthConfig.clientId,
|
|
365
|
+
code_verifier: codeVerifier,
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
const errorText = await response.text();
|
|
371
|
+
return { success: false, error: `Token exchange failed: ${errorText}` };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const tokenData = (await response.json()) as {
|
|
375
|
+
access_token: string;
|
|
376
|
+
refresh_token?: string;
|
|
377
|
+
token_type?: string;
|
|
378
|
+
scope?: string;
|
|
379
|
+
expires_in?: number;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const expiresAt = tokenData.expires_in
|
|
383
|
+
? new Date(Date.now() + tokenData.expires_in * 1000)
|
|
384
|
+
: null;
|
|
385
|
+
|
|
386
|
+
// Store token
|
|
387
|
+
await this.storeToken({
|
|
388
|
+
mcpServerId: serverId,
|
|
389
|
+
accessToken: tokenData.access_token,
|
|
390
|
+
refreshToken: tokenData.refresh_token,
|
|
391
|
+
tokenType: tokenData.token_type || 'Bearer',
|
|
392
|
+
scope: tokenData.scope,
|
|
393
|
+
expiresAt,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return { success: true };
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
399
|
+
return { success: false, error: message };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
186
403
|
/**
|
|
187
404
|
* Generate a random code verifier for PKCE
|
|
188
405
|
*/
|