@dxheroes/local-mcp-backend 0.9.2 → 0.11.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 +52 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +283 -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 +171 -110
- 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 +63 -2
- package/dist/main.js.map +1 -1
- package/dist/modules/auth/auth.config.js +10 -5
- 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 +95 -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/health/health.controller.js +1 -1
- package/dist/modules/health/health.controller.js.map +1 -1
- 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 +9 -7
- package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +311 -0
- package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
- package/src/__tests__/integration/proxy-auth.test.ts +151 -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 +56 -2
- package/src/modules/auth/auth.config.ts +9 -4
- 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 +102 -0
- package/src/modules/auth/mcp-oauth.utils.ts +80 -0
- package/src/modules/health/health.controller.ts +1 -1
- 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
package/src/main.ts
CHANGED
|
@@ -10,11 +10,17 @@ import { ConfigService } from '@nestjs/config';
|
|
|
10
10
|
import { NestFactory } from '@nestjs/core';
|
|
11
11
|
import { toNodeHandler } from 'better-auth/node';
|
|
12
12
|
import compression from 'compression';
|
|
13
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
13
14
|
import helmet from 'helmet';
|
|
14
15
|
import { AppModule } from './app.module.js';
|
|
15
16
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';
|
|
16
17
|
import { LoggingInterceptor } from './common/interceptors/logging.interceptor.js';
|
|
17
18
|
import { AuthService } from './modules/auth/auth.service.js';
|
|
19
|
+
import {
|
|
20
|
+
createMcpProtectedResourceMetadata,
|
|
21
|
+
resolvePublicAuthBaseUrl,
|
|
22
|
+
resolvePublicBackendOrigin,
|
|
23
|
+
} from './modules/auth/mcp-oauth.utils.js';
|
|
18
24
|
|
|
19
25
|
async function bootstrap() {
|
|
20
26
|
// Determine log levels from environment
|
|
@@ -34,6 +40,10 @@ async function bootstrap() {
|
|
|
34
40
|
|
|
35
41
|
// Security
|
|
36
42
|
app.use(helmet());
|
|
43
|
+
app.use((_req: Request, res: Response, next: NextFunction) => {
|
|
44
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
|
|
45
|
+
next();
|
|
46
|
+
});
|
|
37
47
|
app.use(compression());
|
|
38
48
|
|
|
39
49
|
// CORS
|
|
@@ -72,12 +82,56 @@ async function bootstrap() {
|
|
|
72
82
|
const authService = app.get(AuthService);
|
|
73
83
|
const expressApp = app.getHttpAdapter().getInstance();
|
|
74
84
|
|
|
75
|
-
const lazyAuthHandler = (req:
|
|
85
|
+
const lazyAuthHandler = async (req: Request, res: Response, next: NextFunction) => {
|
|
76
86
|
const auth = authService.getAuth();
|
|
77
87
|
if (!auth) return next();
|
|
78
|
-
|
|
88
|
+
try {
|
|
89
|
+
await toNodeHandler(auth)(req, res);
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
const isPrismaNotFound =
|
|
92
|
+
error instanceof Error &&
|
|
93
|
+
'code' in error &&
|
|
94
|
+
(error as { code: string }).code === 'P2025';
|
|
95
|
+
if (isPrismaNotFound) {
|
|
96
|
+
// Stale session cookie — clear it and return 401
|
|
97
|
+
res.clearCookie('better-auth.session_token');
|
|
98
|
+
res.clearCookie('better-auth.session_token.sig');
|
|
99
|
+
if (!res.headersSent) {
|
|
100
|
+
res.status(401).json({ error: 'Session expired' });
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!res.headersSent) {
|
|
105
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
79
108
|
};
|
|
80
109
|
|
|
110
|
+
expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
|
111
|
+
res.json(createMcpProtectedResourceMetadata(configService));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// RFC 8414 – OAuth 2.0 Authorization Server Metadata
|
|
115
|
+
// MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)
|
|
116
|
+
// instead of falling back to the root /register which returns 404.
|
|
117
|
+
expressApp.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
|
|
118
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
119
|
+
const authBaseUrl = resolvePublicAuthBaseUrl(configService);
|
|
120
|
+
|
|
121
|
+
res.json({
|
|
122
|
+
issuer: backendOrigin,
|
|
123
|
+
authorization_endpoint: `${authBaseUrl}/mcp/authorize`,
|
|
124
|
+
token_endpoint: `${authBaseUrl}/mcp/token`,
|
|
125
|
+
registration_endpoint: `${authBaseUrl}/mcp/register`,
|
|
126
|
+
jwks_uri: `${authBaseUrl}/mcp/jwks`,
|
|
127
|
+
response_types_supported: ['code'],
|
|
128
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
129
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
130
|
+
code_challenge_methods_supported: ['S256'],
|
|
131
|
+
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
81
135
|
expressApp.all('/api/auth/*splat', lazyAuthHandler);
|
|
82
136
|
expressApp.all('/.well-known/*splat', lazyAuthHandler);
|
|
83
137
|
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Better Auth Configuration
|
|
3
3
|
*
|
|
4
|
-
* Configures Better Auth with Prisma adapter, email+password (
|
|
5
|
-
* optional Google OAuth, Organization plugin,
|
|
4
|
+
* Configures Better Auth with Prisma adapter, email+password (toggleable via
|
|
5
|
+
* AUTH_EMAIL_PASSWORD env var), optional Google OAuth, Organization plugin,
|
|
6
|
+
* and MCP OAuth plugin.
|
|
6
7
|
* Auto-creates a default organization on user signup.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { PrismaClient } from '@dxheroes/local-mcp-database/generated/prisma';
|
|
11
|
+
import { ConfigService } from '@nestjs/config';
|
|
10
12
|
import { betterAuth } from 'better-auth';
|
|
11
13
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
|
12
14
|
import { mcp } from 'better-auth/plugins';
|
|
13
15
|
import { organization } from 'better-auth/plugins/organization';
|
|
16
|
+
import { resolveMcpLoginPageUrl } from './mcp-oauth.utils.js';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Auth wrapper — simplified interface to avoid exporting Better Auth's deep generic types.
|
|
@@ -37,6 +40,8 @@ function toSlug(name: string): string {
|
|
|
37
40
|
|
|
38
41
|
export function createAuth(prisma: PrismaClient): AuthInstance {
|
|
39
42
|
const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
|
|
43
|
+
const emailPasswordEnabled = process.env.AUTH_EMAIL_PASSWORD !== 'false';
|
|
44
|
+
const configService = new ConfigService();
|
|
40
45
|
|
|
41
46
|
const auth = betterAuth({
|
|
42
47
|
basePath: '/api/auth',
|
|
@@ -47,7 +52,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
|
|
|
47
52
|
'http://localhost:3000',
|
|
48
53
|
]),
|
|
49
54
|
database: prismaAdapter(prisma, { provider: 'postgresql' }),
|
|
50
|
-
emailAndPassword: { enabled:
|
|
55
|
+
emailAndPassword: { enabled: emailPasswordEnabled },
|
|
51
56
|
...(hasGoogle && {
|
|
52
57
|
socialProviders: {
|
|
53
58
|
google: {
|
|
@@ -106,7 +111,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
|
|
|
106
111
|
plugins: [
|
|
107
112
|
organization(),
|
|
108
113
|
mcp({
|
|
109
|
-
loginPage:
|
|
114
|
+
loginPage: resolveMcpLoginPageUrl(configService),
|
|
110
115
|
}),
|
|
111
116
|
],
|
|
112
117
|
});
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
import { Global, Module } from '@nestjs/common';
|
|
8
8
|
import { AuthGuard } from './auth.guard.js';
|
|
9
9
|
import { AuthService } from './auth.service.js';
|
|
10
|
+
import { McpOAuthGuard } from './mcp-oauth.guard.js';
|
|
10
11
|
|
|
11
12
|
@Global()
|
|
12
13
|
@Module({
|
|
13
|
-
providers: [AuthService, AuthGuard],
|
|
14
|
-
exports: [AuthService, AuthGuard],
|
|
14
|
+
providers: [AuthService, AuthGuard, McpOAuthGuard],
|
|
15
|
+
exports: [AuthService, AuthGuard, McpOAuthGuard],
|
|
15
16
|
})
|
|
16
17
|
export class AuthModule {}
|
|
@@ -101,13 +101,13 @@ export class AuthService implements OnModuleInit {
|
|
|
101
101
|
async validateMcpToken(bearerToken: string): Promise<AuthUser | null> {
|
|
102
102
|
try {
|
|
103
103
|
const tokenRecord = await this.prisma.oauthAccessToken.findFirst({
|
|
104
|
-
where: {
|
|
104
|
+
where: { accessToken: bearerToken },
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
if (!tokenRecord || !tokenRecord.userId) return null;
|
|
108
108
|
|
|
109
109
|
// Check expiration
|
|
110
|
-
if (
|
|
110
|
+
if (new Date(tokenRecord.accessTokenExpiresAt) < new Date()) {
|
|
111
111
|
return null;
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Guard
|
|
3
|
+
*
|
|
4
|
+
* Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.
|
|
5
|
+
* Validates Bearer tokens and returns RFC 9728-compliant
|
|
6
|
+
* WWW-Authenticate headers to guide MCP clients through OAuth discovery.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CanActivate,
|
|
11
|
+
ExecutionContext,
|
|
12
|
+
Injectable,
|
|
13
|
+
UnauthorizedException,
|
|
14
|
+
} from '@nestjs/common';
|
|
15
|
+
import { ConfigService } from '@nestjs/config';
|
|
16
|
+
import type { Request } from 'express';
|
|
17
|
+
import { type AuthUser, AuthService } from './auth.service.js';
|
|
18
|
+
import { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';
|
|
19
|
+
|
|
20
|
+
type McpUnauthorizedException = UnauthorizedException & {
|
|
21
|
+
wwwAuthenticate?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class McpOAuthGuard implements CanActivate {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly authService: AuthService,
|
|
28
|
+
private readonly configService: ConfigService
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
32
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
33
|
+
const token = this.extractToken(request);
|
|
34
|
+
|
|
35
|
+
// 1. Try Bearer token (MCP clients like Claude Desktop, Cursor, etc.)
|
|
36
|
+
if (token) {
|
|
37
|
+
const user = await this.authService.validateMcpToken(token);
|
|
38
|
+
if (user) {
|
|
39
|
+
request.user = user;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Try session cookie (browser UI calling /info endpoints)
|
|
46
|
+
const session = await this.validateSessionCookie(request);
|
|
47
|
+
if (session) {
|
|
48
|
+
request.user = session;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Neither worked — require Bearer token (for MCP client discovery)
|
|
53
|
+
throw this.createUnauthorizedError('Bearer token required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Attempt to validate session cookie from the request.
|
|
58
|
+
* Returns the user if a valid session exists, null otherwise.
|
|
59
|
+
*/
|
|
60
|
+
private async validateSessionCookie(request: Request): Promise<AuthUser | null> {
|
|
61
|
+
try {
|
|
62
|
+
const headers = new Headers();
|
|
63
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
64
|
+
if (value) {
|
|
65
|
+
headers.set(key, Array.isArray(value) ? value.join(', ') : value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const result = await this.authService.getSession(headers);
|
|
69
|
+
return result?.user ?? null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract Bearer token from Authorization header or access_token query param (SSE fallback).
|
|
77
|
+
*/
|
|
78
|
+
private extractToken(request: Request): string | null {
|
|
79
|
+
const authHeader = request.headers.authorization;
|
|
80
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
81
|
+
return authHeader.slice(7);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// SSE connections may pass token as query parameter
|
|
85
|
+
const queryToken = request.query.access_token;
|
|
86
|
+
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
|
87
|
+
return queryToken;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create UnauthorizedException with RFC 9728 WWW-Authenticate header
|
|
95
|
+
* pointing MCP clients to the protected resource metadata.
|
|
96
|
+
*/
|
|
97
|
+
private createUnauthorizedError(message: string): UnauthorizedException {
|
|
98
|
+
const error = new UnauthorizedException(message) as McpUnauthorizedException;
|
|
99
|
+
error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);
|
|
100
|
+
return error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -31,7 +31,7 @@ export class HealthController {
|
|
|
31
31
|
@Get('auth-config')
|
|
32
32
|
getAuthConfig() {
|
|
33
33
|
return {
|
|
34
|
-
emailAndPassword:
|
|
34
|
+
emailAndPassword: process.env.AUTH_EMAIL_PASSWORD !== 'false',
|
|
35
35
|
google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
@@ -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
|
*/
|