@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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +52 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +283 -0
  4. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
  5. package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
  6. package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
  7. package/dist/__tests__/integration/proxy-auth.test.js +171 -110
  8. package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
  9. package/dist/__tests__/unit/auth.guard.test.js +23 -2
  10. package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
  11. package/dist/common/filters/all-exceptions.filter.js +6 -0
  12. package/dist/common/filters/all-exceptions.filter.js.map +1 -1
  13. package/dist/main.js +63 -2
  14. package/dist/main.js.map +1 -1
  15. package/dist/modules/auth/auth.config.js +10 -5
  16. package/dist/modules/auth/auth.config.js.map +1 -1
  17. package/dist/modules/auth/auth.module.js +5 -2
  18. package/dist/modules/auth/auth.module.js.map +1 -1
  19. package/dist/modules/auth/auth.service.js +2 -2
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/auth/mcp-oauth.guard.js +95 -0
  22. package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
  23. package/dist/modules/auth/mcp-oauth.utils.js +75 -0
  24. package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
  25. package/dist/modules/health/health.controller.js +1 -1
  26. package/dist/modules/health/health.controller.js.map +1 -1
  27. package/dist/modules/mcp/mcp.service.js +48 -8
  28. package/dist/modules/mcp/mcp.service.js.map +1 -1
  29. package/dist/modules/oauth/oauth.controller.js +78 -1
  30. package/dist/modules/oauth/oauth.controller.js.map +1 -1
  31. package/dist/modules/oauth/oauth.service.js +197 -1
  32. package/dist/modules/oauth/oauth.service.js.map +1 -1
  33. package/dist/modules/proxy/proxy.controller.js +152 -27
  34. package/dist/modules/proxy/proxy.controller.js.map +1 -1
  35. package/dist/modules/proxy/proxy.service.js +28 -4
  36. package/dist/modules/proxy/proxy.service.js.map +1 -1
  37. package/docker-entrypoint.sh +15 -2
  38. package/package.json +9 -7
  39. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +311 -0
  40. package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
  41. package/src/__tests__/integration/proxy-auth.test.ts +151 -168
  42. package/src/__tests__/unit/auth.guard.test.ts +12 -2
  43. package/src/common/filters/all-exceptions.filter.ts +11 -0
  44. package/src/main.ts +56 -2
  45. package/src/modules/auth/auth.config.ts +9 -4
  46. package/src/modules/auth/auth.module.ts +3 -2
  47. package/src/modules/auth/auth.service.ts +2 -2
  48. package/src/modules/auth/mcp-oauth.guard.ts +102 -0
  49. package/src/modules/auth/mcp-oauth.utils.ts +80 -0
  50. package/src/modules/health/health.controller.ts +1 -1
  51. package/src/modules/mcp/mcp.service.ts +54 -12
  52. package/src/modules/oauth/oauth.controller.ts +84 -1
  53. package/src/modules/oauth/oauth.service.ts +218 -1
  54. package/src/modules/proxy/proxy.controller.ts +120 -25
  55. package/src/modules/proxy/proxy.service.ts +26 -4
  56. 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 that:
5
- * - Proxy routes are @Public() (no session cookie required)
6
- * - Bearer token validation via resolveUser flow
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
- * - Unauthenticated access uses __unauthenticated__ sentinel
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
- describe('Proxy controller auth', () => {
84
- let controller: InstanceType<typeof ProxyController>;
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
- // Unauthenticated access (no Bearer token)
92
- // ────────────────────────────────────────────────
93
-
94
- describe('unauthenticated access (no Bearer token)', () => {
95
- beforeEach(() => {
96
- authService = createMockAuthService();
97
- proxyService = createMockProxyService();
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
- it('org-scoped POST request passes __unauthenticated__ to proxy service', async () => {
110
- const req = createMockRequest();
111
- const mcpRequest: McpRequest = {
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
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
129
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
130
+ });
118
131
 
119
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
120
- 'my-profile',
121
- 'my-org',
122
- mcpRequest,
123
- '__unauthenticated__'
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
- it('gateway POST request passes __unauthenticated__', async () => {
128
- const req = createMockRequest();
129
- const mcpRequest: McpRequest = {
130
- jsonrpc: '2.0',
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
- await controller.handleGatewayRequest(req, mcpRequest);
150
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
151
+ expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');
152
+ });
136
153
 
137
- expect(proxyService.handleRequest).toHaveBeenCalledWith(
138
- 'default',
139
- mcpRequest,
140
- '__unauthenticated__'
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
- it('non-Bearer Authorization header falls back to __unauthenticated__', async () => {
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
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
162
+ expect(result).toBe(true);
163
+ expect(req.user).toEqual(user);
164
+ });
155
165
 
156
- expect(authService.validateMcpToken).not.toHaveBeenCalled();
157
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
158
- 'my-profile',
159
- 'my-org',
160
- mcpRequest,
161
- '__unauthenticated__'
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
- // Authenticated access with Bearer tokens
168
- // ────────────────────────────────────────────────
169
-
170
- describe('authenticated access with Bearer tokens', () => {
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
- it('valid Bearer token resolves to authenticated user for org-scoped request', async () => {
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
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
186
+ expect(result).toBe(true);
187
+ expect(req.user).toEqual(user);
188
+ expect(authService.validateMcpToken).not.toHaveBeenCalled();
189
+ });
203
190
 
204
- expect(authService.validateMcpToken).toHaveBeenCalledWith('valid-mcp-token');
205
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
206
- 'my-profile',
207
- 'my-org',
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
- it('invalid Bearer token throws UnauthorizedException', async () => {
214
- authService.validateMcpToken.mockResolvedValue(null);
215
- const req = createMockRequest({
216
- authorization: 'Bearer invalid-token',
217
- });
218
- const mcpRequest: McpRequest = {
219
- jsonrpc: '2.0',
220
- id: 1,
221
- method: 'tools/list',
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
- it('no Authorization header falls back to __unauthenticated__', async () => {
230
- const req = createMockRequest();
231
- const mcpRequest: McpRequest = {
232
- jsonrpc: '2.0',
233
- id: 1,
234
- method: 'tools/list',
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
- '__unauthenticated__'
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: null };
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
  }