@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { OAuthController } from '../../modules/oauth/oauth.controller.js';
|
|
4
|
+
import { OAuthService } from '../../modules/oauth/oauth.service.js';
|
|
5
|
+
|
|
6
|
+
function createOAuthServiceMock() {
|
|
7
|
+
return {
|
|
8
|
+
discoverAndAuthorize: vi.fn(),
|
|
9
|
+
handleCallback: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('OAuth authorize and callback HTTP contract', () => {
|
|
14
|
+
let close: (() => Promise<void>) | undefined;
|
|
15
|
+
let oauthService: ReturnType<typeof createOAuthServiceMock>;
|
|
16
|
+
let baseUrl: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
oauthService = createOAuthServiceMock();
|
|
20
|
+
const controller = new OAuthController(oauthService as unknown as OAuthService);
|
|
21
|
+
|
|
22
|
+
const server = await new Promise<import('node:http').Server>((resolve) => {
|
|
23
|
+
const listeningServer = createServer(async (req, res) => {
|
|
24
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
|
|
25
|
+
const request = {
|
|
26
|
+
protocol: 'http',
|
|
27
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
28
|
+
get: (headerName: string) => {
|
|
29
|
+
if (headerName.toLowerCase() === 'host') {
|
|
30
|
+
return req.headers.host ?? '';
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const response = {
|
|
36
|
+
redirect(location: string) {
|
|
37
|
+
res.statusCode = 302;
|
|
38
|
+
res.setHeader('location', location);
|
|
39
|
+
res.end();
|
|
40
|
+
},
|
|
41
|
+
status(statusCode: number) {
|
|
42
|
+
res.statusCode = statusCode;
|
|
43
|
+
return response;
|
|
44
|
+
},
|
|
45
|
+
send(body: string) {
|
|
46
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
47
|
+
res.end(body);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/oauth/authorize/')) {
|
|
52
|
+
const serverId = url.pathname.split('/').pop() ?? '';
|
|
53
|
+
await controller.authorize(serverId, request as never, response as never);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (req.method === 'GET' && url.pathname === '/api/oauth/callback') {
|
|
58
|
+
await controller.callback(
|
|
59
|
+
typeof request.query.code === 'string' ? request.query.code : '',
|
|
60
|
+
typeof request.query.state === 'string' ? request.query.state : '',
|
|
61
|
+
request as never,
|
|
62
|
+
response as never
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
res.statusCode = 404;
|
|
68
|
+
res.end();
|
|
69
|
+
}).listen(0, () => resolve(listeningServer));
|
|
70
|
+
});
|
|
71
|
+
const address = server.address();
|
|
72
|
+
if (!address || typeof address === 'string') {
|
|
73
|
+
throw new Error('Failed to resolve test server address');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
close = () =>
|
|
77
|
+
new Promise<void>((resolve, reject) => {
|
|
78
|
+
server.close((error) => {
|
|
79
|
+
if (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
if (close) {
|
|
91
|
+
await close();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('redirects authorize requests to the discovered authorization URL', async () => {
|
|
96
|
+
oauthService.discoverAndAuthorize.mockResolvedValue('https://auth.example.com/authorize?client_id=abc');
|
|
97
|
+
|
|
98
|
+
const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`, {
|
|
99
|
+
redirect: 'manual',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(response.status).toBe(302);
|
|
103
|
+
expect(response.headers.get('location')).toBe('https://auth.example.com/authorize?client_id=abc');
|
|
104
|
+
expect(oauthService.discoverAndAuthorize).toHaveBeenCalledWith(
|
|
105
|
+
'server-1',
|
|
106
|
+
`${baseUrl}/api/oauth/callback`
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renders an error page when authorize discovery fails', async () => {
|
|
111
|
+
oauthService.discoverAndAuthorize.mockRejectedValue(new Error('Metadata discovery failed'));
|
|
112
|
+
|
|
113
|
+
const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`);
|
|
114
|
+
const html = await response.text();
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(400);
|
|
117
|
+
expect(html).toContain('Error: Metadata discovery failed');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects callbacks without code or state', async () => {
|
|
121
|
+
const response = await fetch(`${baseUrl}/api/oauth/callback`);
|
|
122
|
+
const html = await response.text();
|
|
123
|
+
|
|
124
|
+
expect(response.status).toBe(400);
|
|
125
|
+
expect(html).toContain('Missing code or state parameter');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('renders a success page for valid callbacks', async () => {
|
|
129
|
+
oauthService.handleCallback.mockResolvedValue({ success: true });
|
|
130
|
+
|
|
131
|
+
const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
|
|
132
|
+
const html = await response.text();
|
|
133
|
+
|
|
134
|
+
expect(response.status).toBe(200);
|
|
135
|
+
expect(html).toContain('Authorization successful! This window will close.');
|
|
136
|
+
expect(oauthService.handleCallback).toHaveBeenCalledWith(
|
|
137
|
+
'server-1',
|
|
138
|
+
'auth-code',
|
|
139
|
+
`${baseUrl}/api/oauth/callback`
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders a failure page when callback token exchange fails', async () => {
|
|
144
|
+
oauthService.handleCallback.mockResolvedValue({
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'Token exchange failed',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
|
|
150
|
+
const html = await response.text();
|
|
151
|
+
|
|
152
|
+
expect(response.status).toBe(200);
|
|
153
|
+
expect(html).toContain('Error: Token exchange failed');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Integration Tests: Proxy controller auth
|
|
3
3
|
*
|
|
4
|
-
* Verifies
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - McpOAuthGuard always enforces Bearer token
|
|
6
|
+
* - WWW-Authenticate header with resource_metadata_uri on 401
|
|
7
|
+
* - Valid tokens resolve user and pass through
|
|
7
8
|
* - Org-scoped profile lookup through the proxy service
|
|
8
|
-
* -
|
|
9
|
-
* - Gateway endpoint still works with handleGatewayRequest
|
|
9
|
+
* - Gateway endpoint uses default profile with user scoping
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
@@ -17,6 +17,7 @@ import type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/
|
|
|
17
17
|
import type { SettingsService } from '../../modules/settings/settings.service.js';
|
|
18
18
|
|
|
19
19
|
// Dynamic import to handle ESM
|
|
20
|
+
const { McpOAuthGuard } = await import('../../modules/auth/mcp-oauth.guard.js');
|
|
20
21
|
const { ProxyController } = await import('../../modules/proxy/proxy.controller.js');
|
|
21
22
|
|
|
22
23
|
// ────────────────────────────────────────────────
|
|
@@ -33,6 +34,17 @@ function createMockAuthService() {
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function createMockConfigService(overrides: Record<string, unknown> = {}) {
|
|
38
|
+
const defaults: Record<string, unknown> = {
|
|
39
|
+
'app.port': 3001,
|
|
40
|
+
BETTER_AUTH_URL: 'http://localhost:3001',
|
|
41
|
+
};
|
|
42
|
+
const config = { ...defaults, ...overrides };
|
|
43
|
+
return {
|
|
44
|
+
get: vi.fn((key: string) => config[key]),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
function createMockProxyService() {
|
|
37
49
|
return {
|
|
38
50
|
handleRequest: vi.fn().mockResolvedValue({
|
|
@@ -73,204 +85,177 @@ function createMockEventEmitter() {
|
|
|
73
85
|
};
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
function createMockRequest(headers: Record<string, string> = {}) {
|
|
88
|
+
function createMockRequest(headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
|
77
89
|
return {
|
|
78
90
|
headers,
|
|
91
|
+
query,
|
|
79
92
|
on: vi.fn(),
|
|
93
|
+
user: undefined as any,
|
|
80
94
|
} as unknown as import('express').Request;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
function createMockExecutionContext(req: import('express').Request) {
|
|
98
|
+
return {
|
|
99
|
+
switchToHttp: () => ({
|
|
100
|
+
getRequest: () => req,
|
|
101
|
+
getResponse: () => ({}),
|
|
102
|
+
}),
|
|
103
|
+
getHandler: () => ({}),
|
|
104
|
+
getClass: () => ({}),
|
|
105
|
+
} as any;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ────────────────────────────────────────────────
|
|
109
|
+
// McpOAuthGuard tests
|
|
110
|
+
// ────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('McpOAuthGuard', () => {
|
|
113
|
+
let guard: InstanceType<typeof McpOAuthGuard>;
|
|
85
114
|
let authService: ReturnType<typeof createMockAuthService>;
|
|
86
|
-
let proxyService: ReturnType<typeof createMockProxyService>;
|
|
87
|
-
let settingsService: ReturnType<typeof createMockSettingsService>;
|
|
88
|
-
let eventEmitter: ReturnType<typeof createMockEventEmitter>;
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
settingsService = createMockSettingsService();
|
|
99
|
-
eventEmitter = createMockEventEmitter();
|
|
100
|
-
|
|
101
|
-
controller = new ProxyController(
|
|
102
|
-
proxyService as unknown as ProxyService,
|
|
103
|
-
settingsService as unknown as SettingsService,
|
|
104
|
-
eventEmitter as unknown as EventEmitter2,
|
|
105
|
-
authService as unknown as AuthService
|
|
106
|
-
);
|
|
107
|
-
});
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
authService = createMockAuthService();
|
|
118
|
+
const configService = createMockConfigService();
|
|
119
|
+
guard = new McpOAuthGuard(
|
|
120
|
+
authService as unknown as AuthService,
|
|
121
|
+
configService as any
|
|
122
|
+
);
|
|
123
|
+
});
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
jsonrpc: '2.0',
|
|
113
|
-
id: 1,
|
|
114
|
-
method: 'tools/list',
|
|
115
|
-
};
|
|
125
|
+
it('rejects request without Bearer token with 401', async () => {
|
|
126
|
+
const req = createMockRequest();
|
|
127
|
+
const ctx = createMockExecutionContext(req);
|
|
116
128
|
|
|
117
|
-
|
|
129
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
130
|
+
});
|
|
118
131
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
125
|
-
})
|
|
132
|
+
it('returns WWW-Authenticate header on missing token', async () => {
|
|
133
|
+
const req = createMockRequest();
|
|
134
|
+
const ctx = createMockExecutionContext(req);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await guard.canActivate(ctx);
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
expect(error.wwwAuthenticate).toContain('resource_metadata=');
|
|
140
|
+
expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');
|
|
141
|
+
expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
126
144
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
id: 1,
|
|
132
|
-
method: 'initialize',
|
|
133
|
-
};
|
|
145
|
+
it('rejects invalid Bearer token', async () => {
|
|
146
|
+
authService.validateMcpToken.mockResolvedValue(null);
|
|
147
|
+
const req = createMockRequest({ authorization: 'Bearer bad-token' });
|
|
148
|
+
const ctx = createMockExecutionContext(req);
|
|
134
149
|
|
|
135
|
-
|
|
150
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
151
|
+
expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');
|
|
152
|
+
});
|
|
136
153
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
154
|
+
it('allows valid Bearer token and attaches user', async () => {
|
|
155
|
+
const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };
|
|
156
|
+
authService.validateMcpToken.mockResolvedValue(user);
|
|
157
|
+
const req = createMockRequest({ authorization: 'Bearer valid-token' });
|
|
158
|
+
const ctx = createMockExecutionContext(req);
|
|
143
159
|
|
|
144
|
-
|
|
145
|
-
const req = createMockRequest({
|
|
146
|
-
authorization: 'Basic dXNlcjpwYXNz',
|
|
147
|
-
});
|
|
148
|
-
const mcpRequest: McpRequest = {
|
|
149
|
-
jsonrpc: '2.0',
|
|
150
|
-
id: 1,
|
|
151
|
-
method: 'tools/list',
|
|
152
|
-
};
|
|
160
|
+
const result = await guard.canActivate(ctx);
|
|
153
161
|
|
|
154
|
-
|
|
162
|
+
expect(result).toBe(true);
|
|
163
|
+
expect(req.user).toEqual(user);
|
|
164
|
+
});
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
it('accepts access_token query param (SSE fallback)', async () => {
|
|
167
|
+
const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };
|
|
168
|
+
authService.validateMcpToken.mockResolvedValue(user);
|
|
169
|
+
const req = createMockRequest({}, { access_token: 'sse-token' });
|
|
170
|
+
const ctx = createMockExecutionContext(req);
|
|
171
|
+
|
|
172
|
+
const result = await guard.canActivate(ctx);
|
|
173
|
+
|
|
174
|
+
expect(result).toBe(true);
|
|
175
|
+
expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
|
|
164
176
|
});
|
|
165
177
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const validUser: AuthUser = {
|
|
172
|
-
id: 'user-1',
|
|
173
|
-
name: 'Token User',
|
|
174
|
-
email: 'token@example.com',
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
beforeEach(() => {
|
|
178
|
-
authService = createMockAuthService();
|
|
179
|
-
proxyService = createMockProxyService();
|
|
180
|
-
settingsService = createMockSettingsService();
|
|
181
|
-
eventEmitter = createMockEventEmitter();
|
|
182
|
-
|
|
183
|
-
controller = new ProxyController(
|
|
184
|
-
proxyService as unknown as ProxyService,
|
|
185
|
-
settingsService as unknown as SettingsService,
|
|
186
|
-
eventEmitter as unknown as EventEmitter2,
|
|
187
|
-
authService as unknown as AuthService
|
|
188
|
-
);
|
|
189
|
-
});
|
|
178
|
+
it('accepts session cookie when no Bearer token is present', async () => {
|
|
179
|
+
const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };
|
|
180
|
+
authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });
|
|
181
|
+
const req = createMockRequest({ cookie: 'better-auth.session_token=abc123' });
|
|
182
|
+
const ctx = createMockExecutionContext(req);
|
|
190
183
|
|
|
191
|
-
|
|
192
|
-
authService.validateMcpToken.mockResolvedValue(validUser);
|
|
193
|
-
const req = createMockRequest({
|
|
194
|
-
authorization: 'Bearer valid-mcp-token',
|
|
195
|
-
});
|
|
196
|
-
const mcpRequest: McpRequest = {
|
|
197
|
-
jsonrpc: '2.0',
|
|
198
|
-
id: 1,
|
|
199
|
-
method: 'tools/list',
|
|
200
|
-
};
|
|
184
|
+
const result = await guard.canActivate(ctx);
|
|
201
185
|
|
|
202
|
-
|
|
186
|
+
expect(result).toBe(true);
|
|
187
|
+
expect(req.user).toEqual(user);
|
|
188
|
+
expect(authService.validateMcpToken).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
203
190
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
mcpRequest,
|
|
209
|
-
'user-1'
|
|
210
|
-
);
|
|
211
|
-
});
|
|
191
|
+
it('rejects when neither Bearer token nor session cookie is valid', async () => {
|
|
192
|
+
authService.getSession.mockResolvedValue(null);
|
|
193
|
+
const req = createMockRequest({ cookie: 'better-auth.session_token=expired' });
|
|
194
|
+
const ctx = createMockExecutionContext(req);
|
|
212
195
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
await expect(
|
|
225
|
-
controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest)
|
|
226
|
-
).rejects.toThrow(UnauthorizedException);
|
|
227
|
-
});
|
|
196
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('does not fall through to session cookie when Bearer token is invalid', async () => {
|
|
200
|
+
const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };
|
|
201
|
+
authService.validateMcpToken.mockResolvedValue(null);
|
|
202
|
+
authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });
|
|
203
|
+
const req = createMockRequest({ authorization: 'Bearer bad-token' });
|
|
204
|
+
const ctx = createMockExecutionContext(req);
|
|
228
205
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
206
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
207
|
+
expect(authService.getSession).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ────────────────────────────────────────────────
|
|
212
|
+
// Proxy controller auth tests
|
|
213
|
+
// ────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe('Proxy controller auth', () => {
|
|
216
|
+
let controller: InstanceType<typeof ProxyController>;
|
|
217
|
+
let proxyService: ReturnType<typeof createMockProxyService>;
|
|
218
|
+
let settingsService: ReturnType<typeof createMockSettingsService>;
|
|
219
|
+
let eventEmitter: ReturnType<typeof createMockEventEmitter>;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
proxyService = createMockProxyService();
|
|
223
|
+
settingsService = createMockSettingsService();
|
|
224
|
+
eventEmitter = createMockEventEmitter();
|
|
225
|
+
|
|
226
|
+
controller = new ProxyController(
|
|
227
|
+
proxyService as unknown as ProxyService,
|
|
228
|
+
settingsService as unknown as SettingsService,
|
|
229
|
+
eventEmitter as unknown as EventEmitter2
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('guard-authenticated user passthrough', () => {
|
|
234
|
+
it('uses req.user set by McpOAuthGuard', async () => {
|
|
235
|
+
const guardUser: AuthUser = { id: 'guard-user', name: 'Guard', email: 'g@test.com' };
|
|
236
|
+
const req = createMockRequest({ authorization: 'Bearer some-token' });
|
|
237
|
+
(req as any).user = guardUser;
|
|
238
|
+
|
|
239
|
+
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
236
240
|
|
|
237
241
|
await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
|
|
238
242
|
|
|
239
|
-
expect(authService.validateMcpToken).not.toHaveBeenCalled();
|
|
240
243
|
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
|
|
241
244
|
'my-profile',
|
|
242
245
|
'my-org',
|
|
243
246
|
mcpRequest,
|
|
244
|
-
'
|
|
247
|
+
'guard-user'
|
|
245
248
|
);
|
|
246
249
|
});
|
|
247
250
|
});
|
|
248
251
|
|
|
249
|
-
// ────────────────────────────────────────────────
|
|
250
|
-
// Org-scoped profile lookup through proxy
|
|
251
|
-
// ────────────────────────────────────────────────
|
|
252
|
-
|
|
253
252
|
describe('org-scoped profile lookup', () => {
|
|
254
253
|
const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };
|
|
255
254
|
const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };
|
|
256
255
|
|
|
257
|
-
beforeEach(() => {
|
|
258
|
-
authService = createMockAuthService();
|
|
259
|
-
proxyService = createMockProxyService();
|
|
260
|
-
settingsService = createMockSettingsService();
|
|
261
|
-
eventEmitter = createMockEventEmitter();
|
|
262
|
-
|
|
263
|
-
controller = new ProxyController(
|
|
264
|
-
proxyService as unknown as ProxyService,
|
|
265
|
-
settingsService as unknown as SettingsService,
|
|
266
|
-
eventEmitter as unknown as EventEmitter2,
|
|
267
|
-
authService as unknown as AuthService
|
|
268
|
-
);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
256
|
it('org slug and user are passed through to proxy service', async () => {
|
|
272
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
273
257
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
258
|
+
(req as any).user = userA;
|
|
274
259
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
275
260
|
|
|
276
261
|
await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);
|
|
@@ -284,16 +269,14 @@ describe('Proxy controller auth', () => {
|
|
|
284
269
|
});
|
|
285
270
|
|
|
286
271
|
it('different users get different userId passed to proxy', async () => {
|
|
287
|
-
// First request from User A
|
|
288
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
289
272
|
const reqA = createMockRequest({ authorization: 'Bearer token-a' });
|
|
273
|
+
(reqA as any).user = userA;
|
|
290
274
|
const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
291
275
|
|
|
292
276
|
await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);
|
|
293
277
|
|
|
294
|
-
// Second request from User B
|
|
295
|
-
authService.validateMcpToken.mockResolvedValue(userB);
|
|
296
278
|
const reqB = createMockRequest({ authorization: 'Bearer token-b' });
|
|
279
|
+
(reqB as any).user = userB;
|
|
297
280
|
|
|
298
281
|
await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);
|
|
299
282
|
|
|
@@ -314,10 +297,10 @@ describe('Proxy controller auth', () => {
|
|
|
314
297
|
});
|
|
315
298
|
|
|
316
299
|
it('gateway endpoint uses default profile with user scoping', async () => {
|
|
317
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
318
300
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');
|
|
319
301
|
|
|
320
302
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
303
|
+
(req as any).user = userA;
|
|
321
304
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
322
305
|
|
|
323
306
|
await controller.handleGatewayRequest(req, mcpRequest);
|
|
@@ -326,13 +309,13 @@ describe('Proxy controller auth', () => {
|
|
|
326
309
|
});
|
|
327
310
|
|
|
328
311
|
it('gateway wraps NotFoundException with descriptive message', async () => {
|
|
329
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
330
312
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');
|
|
331
313
|
proxyService.handleRequest.mockRejectedValue(
|
|
332
314
|
new NotFoundException('Profile "missing-profile" not found')
|
|
333
315
|
);
|
|
334
316
|
|
|
335
317
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
318
|
+
(req as any).user = userA;
|
|
336
319
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
337
320
|
|
|
338
321
|
await expect(controller.handleGatewayRequest(req, mcpRequest)).rejects.toThrow(
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for AuthGuard
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
|
5
|
+
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
|
6
6
|
import { Reflector } from '@nestjs/core';
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { AuthGuard } from '../../modules/auth/auth.guard.js';
|
|
@@ -52,7 +52,7 @@ describe('AuthGuard', () => {
|
|
|
52
52
|
|
|
53
53
|
it('should attach user and session when valid session exists', async () => {
|
|
54
54
|
const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };
|
|
55
|
-
const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId:
|
|
55
|
+
const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: 'org-1' };
|
|
56
56
|
authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });
|
|
57
57
|
|
|
58
58
|
const ctx = createMockExecutionContext({ cookie: 'session=abc' });
|
|
@@ -63,4 +63,14 @@ describe('AuthGuard', () => {
|
|
|
63
63
|
expect(request.user).toEqual(mockUser);
|
|
64
64
|
expect(request.sessionData).toEqual(mockSession);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
it('should throw ForbiddenException when session has no active organization', async () => {
|
|
68
|
+
const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };
|
|
69
|
+
const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: null };
|
|
70
|
+
authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });
|
|
71
|
+
|
|
72
|
+
const ctx = createMockExecutionContext({ cookie: 'session=abc' });
|
|
73
|
+
|
|
74
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
|
|
75
|
+
});
|
|
66
76
|
});
|
|
@@ -23,6 +23,10 @@ interface ErrorResponse {
|
|
|
23
23
|
requestId?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
type McpHttpException = HttpException & {
|
|
27
|
+
wwwAuthenticate?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
26
30
|
@Catch()
|
|
27
31
|
export class AllExceptionsFilter implements ExceptionFilter {
|
|
28
32
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
|
@@ -72,6 +76,13 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|
|
72
76
|
errorResponse.requestId = requestId;
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
// Set WWW-Authenticate header for MCP OAuth errors (RFC 9728)
|
|
80
|
+
const mcpException = exception instanceof HttpException ? (exception as McpHttpException) : null;
|
|
81
|
+
if (mcpException?.wwwAuthenticate) {
|
|
82
|
+
response.setHeader('WWW-Authenticate', mcpException.wwwAuthenticate);
|
|
83
|
+
response.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate');
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
response.status(status).json(errorResponse);
|
|
76
87
|
}
|
|
77
88
|
}
|