@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
|
@@ -1,15 +1,16 @@
|
|
|
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
|
*/ import { NotFoundException, UnauthorizedException } from "@nestjs/common";
|
|
11
11
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
// Dynamic import to handle ESM
|
|
13
|
+
const { McpOAuthGuard } = await import("../../modules/auth/mcp-oauth.guard.js");
|
|
13
14
|
const { ProxyController } = await import("../../modules/proxy/proxy.controller.js");
|
|
14
15
|
// ────────────────────────────────────────────────
|
|
15
16
|
// Mock factories
|
|
@@ -23,6 +24,19 @@ function createMockAuthService() {
|
|
|
23
24
|
onModuleInit: vi.fn()
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
function createMockConfigService(overrides = {}) {
|
|
28
|
+
const defaults = {
|
|
29
|
+
'app.port': 3001,
|
|
30
|
+
BETTER_AUTH_URL: 'http://localhost:3001'
|
|
31
|
+
};
|
|
32
|
+
const config = {
|
|
33
|
+
...defaults,
|
|
34
|
+
...overrides
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
get: vi.fn((key)=>config[key])
|
|
38
|
+
};
|
|
39
|
+
}
|
|
26
40
|
function createMockProxyService() {
|
|
27
41
|
return {
|
|
28
42
|
handleRequest: vi.fn().mockResolvedValue({
|
|
@@ -72,120 +86,125 @@ function createMockEventEmitter() {
|
|
|
72
86
|
off: vi.fn()
|
|
73
87
|
};
|
|
74
88
|
}
|
|
75
|
-
function createMockRequest(headers = {}) {
|
|
89
|
+
function createMockRequest(headers = {}, query = {}) {
|
|
76
90
|
return {
|
|
77
91
|
headers,
|
|
78
|
-
|
|
92
|
+
query,
|
|
93
|
+
on: vi.fn(),
|
|
94
|
+
user: undefined
|
|
79
95
|
};
|
|
80
96
|
}
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
function createMockExecutionContext(req) {
|
|
98
|
+
return {
|
|
99
|
+
switchToHttp: ()=>({
|
|
100
|
+
getRequest: ()=>req,
|
|
101
|
+
getResponse: ()=>({})
|
|
102
|
+
}),
|
|
103
|
+
getHandler: ()=>({}),
|
|
104
|
+
getClass: ()=>({})
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// ────────────────────────────────────────────────
|
|
108
|
+
// McpOAuthGuard tests
|
|
109
|
+
// ────────────────────────────────────────────────
|
|
110
|
+
describe('McpOAuthGuard', ()=>{
|
|
111
|
+
let guard;
|
|
83
112
|
let authService;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const req = createMockRequest();
|
|
110
|
-
const mcpRequest = {
|
|
111
|
-
jsonrpc: '2.0',
|
|
112
|
-
id: 1,
|
|
113
|
-
method: 'initialize'
|
|
114
|
-
};
|
|
115
|
-
await controller.handleGatewayRequest(req, mcpRequest);
|
|
116
|
-
expect(proxyService.handleRequest).toHaveBeenCalledWith('default', mcpRequest, '__unauthenticated__');
|
|
113
|
+
beforeEach(()=>{
|
|
114
|
+
authService = createMockAuthService();
|
|
115
|
+
const configService = createMockConfigService();
|
|
116
|
+
guard = new McpOAuthGuard(authService, configService);
|
|
117
|
+
});
|
|
118
|
+
it('rejects request without Bearer token with 401', async ()=>{
|
|
119
|
+
const req = createMockRequest();
|
|
120
|
+
const ctx = createMockExecutionContext(req);
|
|
121
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
122
|
+
});
|
|
123
|
+
it('returns WWW-Authenticate header on missing token', async ()=>{
|
|
124
|
+
const req = createMockRequest();
|
|
125
|
+
const ctx = createMockExecutionContext(req);
|
|
126
|
+
try {
|
|
127
|
+
await guard.canActivate(ctx);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
expect(error.wwwAuthenticate).toContain('resource_metadata=');
|
|
130
|
+
expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');
|
|
131
|
+
expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it('rejects invalid Bearer token', async ()=>{
|
|
135
|
+
authService.validateMcpToken.mockResolvedValue(null);
|
|
136
|
+
const req = createMockRequest({
|
|
137
|
+
authorization: 'Bearer bad-token'
|
|
117
138
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
const ctx = createMockExecutionContext(req);
|
|
140
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
|
|
141
|
+
expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');
|
|
142
|
+
});
|
|
143
|
+
it('allows valid Bearer token and attaches user', async ()=>{
|
|
144
|
+
const user = {
|
|
145
|
+
id: 'user-1',
|
|
146
|
+
name: 'Test',
|
|
147
|
+
email: 'test@example.com'
|
|
148
|
+
};
|
|
149
|
+
authService.validateMcpToken.mockResolvedValue(user);
|
|
150
|
+
const req = createMockRequest({
|
|
151
|
+
authorization: 'Bearer valid-token'
|
|
130
152
|
});
|
|
153
|
+
const ctx = createMockExecutionContext(req);
|
|
154
|
+
const result = await guard.canActivate(ctx);
|
|
155
|
+
expect(result).toBe(true);
|
|
156
|
+
expect(req.user).toEqual(user);
|
|
131
157
|
});
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// ────────────────────────────────────────────────
|
|
135
|
-
describe('authenticated access with Bearer tokens', ()=>{
|
|
136
|
-
const validUser = {
|
|
158
|
+
it('accepts access_token query param (SSE fallback)', async ()=>{
|
|
159
|
+
const user = {
|
|
137
160
|
id: 'user-1',
|
|
138
|
-
name: '
|
|
139
|
-
email: '
|
|
161
|
+
name: 'Test',
|
|
162
|
+
email: 'test@example.com'
|
|
140
163
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
settingsService = createMockSettingsService();
|
|
145
|
-
eventEmitter = createMockEventEmitter();
|
|
146
|
-
controller = new ProxyController(proxyService, settingsService, eventEmitter, authService);
|
|
164
|
+
authService.validateMcpToken.mockResolvedValue(user);
|
|
165
|
+
const req = createMockRequest({}, {
|
|
166
|
+
access_token: 'sse-token'
|
|
147
167
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
const ctx = createMockExecutionContext(req);
|
|
169
|
+
const result = await guard.canActivate(ctx);
|
|
170
|
+
expect(result).toBe(true);
|
|
171
|
+
expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
// ────────────────────────────────────────────────
|
|
175
|
+
// Proxy controller auth tests
|
|
176
|
+
// ────────────────────────────────────────────────
|
|
177
|
+
describe('Proxy controller auth', ()=>{
|
|
178
|
+
let controller;
|
|
179
|
+
let proxyService;
|
|
180
|
+
let settingsService;
|
|
181
|
+
let eventEmitter;
|
|
182
|
+
beforeEach(()=>{
|
|
183
|
+
proxyService = createMockProxyService();
|
|
184
|
+
settingsService = createMockSettingsService();
|
|
185
|
+
eventEmitter = createMockEventEmitter();
|
|
186
|
+
controller = new ProxyController(proxyService, settingsService, eventEmitter);
|
|
187
|
+
});
|
|
188
|
+
describe('guard-authenticated user passthrough', ()=>{
|
|
189
|
+
it('uses req.user set by McpOAuthGuard', async ()=>{
|
|
190
|
+
const guardUser = {
|
|
191
|
+
id: 'guard-user',
|
|
192
|
+
name: 'Guard',
|
|
193
|
+
email: 'g@test.com'
|
|
157
194
|
};
|
|
158
|
-
await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
|
|
159
|
-
expect(authService.validateMcpToken).toHaveBeenCalledWith('valid-mcp-token');
|
|
160
|
-
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org', mcpRequest, 'user-1');
|
|
161
|
-
});
|
|
162
|
-
it('invalid Bearer token throws UnauthorizedException', async ()=>{
|
|
163
|
-
authService.validateMcpToken.mockResolvedValue(null);
|
|
164
195
|
const req = createMockRequest({
|
|
165
|
-
authorization: 'Bearer
|
|
196
|
+
authorization: 'Bearer some-token'
|
|
166
197
|
});
|
|
167
|
-
|
|
168
|
-
jsonrpc: '2.0',
|
|
169
|
-
id: 1,
|
|
170
|
-
method: 'tools/list'
|
|
171
|
-
};
|
|
172
|
-
await expect(controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest)).rejects.toThrow(UnauthorizedException);
|
|
173
|
-
});
|
|
174
|
-
it('no Authorization header falls back to __unauthenticated__', async ()=>{
|
|
175
|
-
const req = createMockRequest();
|
|
198
|
+
req.user = guardUser;
|
|
176
199
|
const mcpRequest = {
|
|
177
200
|
jsonrpc: '2.0',
|
|
178
201
|
id: 1,
|
|
179
202
|
method: 'tools/list'
|
|
180
203
|
};
|
|
181
204
|
await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
|
|
182
|
-
expect(
|
|
183
|
-
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org', mcpRequest, '__unauthenticated__');
|
|
205
|
+
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org', mcpRequest, 'guard-user');
|
|
184
206
|
});
|
|
185
207
|
});
|
|
186
|
-
// ────────────────────────────────────────────────
|
|
187
|
-
// Org-scoped profile lookup through proxy
|
|
188
|
-
// ────────────────────────────────────────────────
|
|
189
208
|
describe('org-scoped profile lookup', ()=>{
|
|
190
209
|
const userA = {
|
|
191
210
|
id: 'user-a',
|
|
@@ -197,18 +216,11 @@ describe('Proxy controller auth', ()=>{
|
|
|
197
216
|
name: 'User B',
|
|
198
217
|
email: 'b@test.com'
|
|
199
218
|
};
|
|
200
|
-
beforeEach(()=>{
|
|
201
|
-
authService = createMockAuthService();
|
|
202
|
-
proxyService = createMockProxyService();
|
|
203
|
-
settingsService = createMockSettingsService();
|
|
204
|
-
eventEmitter = createMockEventEmitter();
|
|
205
|
-
controller = new ProxyController(proxyService, settingsService, eventEmitter, authService);
|
|
206
|
-
});
|
|
207
219
|
it('org slug and user are passed through to proxy service', async ()=>{
|
|
208
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
209
220
|
const req = createMockRequest({
|
|
210
221
|
authorization: 'Bearer token-a'
|
|
211
222
|
});
|
|
223
|
+
req.user = userA;
|
|
212
224
|
const mcpRequest = {
|
|
213
225
|
jsonrpc: '2.0',
|
|
214
226
|
id: 1,
|
|
@@ -218,32 +230,30 @@ describe('Proxy controller auth', ()=>{
|
|
|
218
230
|
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith('user-a-profile', 'org-a', mcpRequest, 'user-a');
|
|
219
231
|
});
|
|
220
232
|
it('different users get different userId passed to proxy', async ()=>{
|
|
221
|
-
// First request from User A
|
|
222
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
223
233
|
const reqA = createMockRequest({
|
|
224
234
|
authorization: 'Bearer token-a'
|
|
225
235
|
});
|
|
236
|
+
reqA.user = userA;
|
|
226
237
|
const mcpReq = {
|
|
227
238
|
jsonrpc: '2.0',
|
|
228
239
|
id: 1,
|
|
229
240
|
method: 'tools/list'
|
|
230
241
|
};
|
|
231
242
|
await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);
|
|
232
|
-
// Second request from User B
|
|
233
|
-
authService.validateMcpToken.mockResolvedValue(userB);
|
|
234
243
|
const reqB = createMockRequest({
|
|
235
244
|
authorization: 'Bearer token-b'
|
|
236
245
|
});
|
|
246
|
+
reqB.user = userB;
|
|
237
247
|
await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);
|
|
238
248
|
expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(1, 'shared-profile', 'shared-org', mcpReq, 'user-a');
|
|
239
249
|
expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(2, 'shared-profile', 'shared-org', mcpReq, 'user-b');
|
|
240
250
|
});
|
|
241
251
|
it('gateway endpoint uses default profile with user scoping', async ()=>{
|
|
242
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
243
252
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');
|
|
244
253
|
const req = createMockRequest({
|
|
245
254
|
authorization: 'Bearer token-a'
|
|
246
255
|
});
|
|
256
|
+
req.user = userA;
|
|
247
257
|
const mcpRequest = {
|
|
248
258
|
jsonrpc: '2.0',
|
|
249
259
|
id: 1,
|
|
@@ -253,12 +263,12 @@ describe('Proxy controller auth', ()=>{
|
|
|
253
263
|
expect(proxyService.handleRequest).toHaveBeenCalledWith('my-default', mcpRequest, 'user-a');
|
|
254
264
|
});
|
|
255
265
|
it('gateway wraps NotFoundException with descriptive message', async ()=>{
|
|
256
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
257
266
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');
|
|
258
267
|
proxyService.handleRequest.mockRejectedValue(new NotFoundException('Profile "missing-profile" not found'));
|
|
259
268
|
const req = createMockRequest({
|
|
260
269
|
authorization: 'Bearer token-a'
|
|
261
270
|
});
|
|
271
|
+
req.user = userA;
|
|
262
272
|
const mcpRequest = {
|
|
263
273
|
jsonrpc: '2.0',
|
|
264
274
|
id: 1,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/__tests__/integration/proxy-auth.test.ts"],"sourcesContent":["/**\n * Integration Tests: Proxy controller auth\n *\n * Verifies that:\n * - Proxy routes are @Public() (no session cookie required)\n * - Bearer token validation via resolveUser flow\n * - Org-scoped profile lookup through the proxy service\n * - Unauthenticated access uses __unauthenticated__ sentinel\n * - Gateway endpoint still works with handleGatewayRequest\n */\n\nimport { NotFoundException, UnauthorizedException } from '@nestjs/common';\nimport type { EventEmitter2 } from '@nestjs/event-emitter';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { type AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/proxy.service.js';\nimport type { SettingsService } from '../../modules/settings/settings.service.js';\n\n// Dynamic import to handle ESM\nconst { ProxyController } = await import('../../modules/proxy/proxy.controller.js');\n\n// ────────────────────────────────────────────────\n// Mock factories\n// ────────────────────────────────────────────────\n\nfunction createMockAuthService() {\n return {\n getAuth: vi.fn(),\n getSession: vi.fn().mockResolvedValue(null),\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getUserOrganizations: vi.fn().mockResolvedValue([]),\n onModuleInit: vi.fn(),\n };\n}\n\nfunction createMockProxyService() {\n return {\n handleRequest: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n handleRequestByOrgSlug: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n getProfileInfo: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getProfileInfoByOrgSlug: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getToolsForServer: vi.fn().mockResolvedValue([]),\n };\n}\n\nfunction createMockSettingsService() {\n return {\n getDefaultGatewayProfile: vi.fn().mockResolvedValue('default'),\n getSetting: vi.fn(),\n setSetting: vi.fn(),\n };\n}\n\nfunction createMockEventEmitter() {\n return {\n emit: vi.fn(),\n on: vi.fn(),\n off: vi.fn(),\n };\n}\n\nfunction createMockRequest(headers: Record<string, string> = {}) {\n return {\n headers,\n on: vi.fn(),\n } as unknown as import('express').Request;\n}\n\ndescribe('Proxy controller auth', () => {\n let controller: InstanceType<typeof ProxyController>;\n let authService: ReturnType<typeof createMockAuthService>;\n let proxyService: ReturnType<typeof createMockProxyService>;\n let settingsService: ReturnType<typeof createMockSettingsService>;\n let eventEmitter: ReturnType<typeof createMockEventEmitter>;\n\n // ────────────────────────────────────────────────\n // Unauthenticated access (no Bearer token)\n // ────────────────────────────────────────────────\n\n describe('unauthenticated access (no Bearer token)', () => {\n beforeEach(() => {\n authService = createMockAuthService();\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2,\n authService as unknown as AuthService\n );\n });\n\n it('org-scoped POST request passes __unauthenticated__ to proxy service', async () => {\n const req = createMockRequest();\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'tools/list',\n };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n '__unauthenticated__'\n );\n });\n\n it('gateway POST request passes __unauthenticated__', async () => {\n const req = createMockRequest();\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'initialize',\n };\n\n await controller.handleGatewayRequest(req, mcpRequest);\n\n expect(proxyService.handleRequest).toHaveBeenCalledWith(\n 'default',\n mcpRequest,\n '__unauthenticated__'\n );\n });\n\n it('non-Bearer Authorization header falls back to __unauthenticated__', async () => {\n const req = createMockRequest({\n authorization: 'Basic dXNlcjpwYXNz',\n });\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'tools/list',\n };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(authService.validateMcpToken).not.toHaveBeenCalled();\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n '__unauthenticated__'\n );\n });\n });\n\n // ────────────────────────────────────────────────\n // Authenticated access with Bearer tokens\n // ────────────────────────────────────────────────\n\n describe('authenticated access with Bearer tokens', () => {\n const validUser: AuthUser = {\n id: 'user-1',\n name: 'Token User',\n email: 'token@example.com',\n };\n\n beforeEach(() => {\n authService = createMockAuthService();\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2,\n authService as unknown as AuthService\n );\n });\n\n it('valid Bearer token resolves to authenticated user for org-scoped request', async () => {\n authService.validateMcpToken.mockResolvedValue(validUser);\n const req = createMockRequest({\n authorization: 'Bearer valid-mcp-token',\n });\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'tools/list',\n };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(authService.validateMcpToken).toHaveBeenCalledWith('valid-mcp-token');\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n 'user-1'\n );\n });\n\n it('invalid Bearer token throws UnauthorizedException', async () => {\n authService.validateMcpToken.mockResolvedValue(null);\n const req = createMockRequest({\n authorization: 'Bearer invalid-token',\n });\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'tools/list',\n };\n\n await expect(\n controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest)\n ).rejects.toThrow(UnauthorizedException);\n });\n\n it('no Authorization header falls back to __unauthenticated__', async () => {\n const req = createMockRequest();\n const mcpRequest: McpRequest = {\n jsonrpc: '2.0',\n id: 1,\n method: 'tools/list',\n };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(authService.validateMcpToken).not.toHaveBeenCalled();\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n '__unauthenticated__'\n );\n });\n });\n\n // ────────────────────────────────────────────────\n // Org-scoped profile lookup through proxy\n // ────────────────────────────────────────────────\n\n describe('org-scoped profile lookup', () => {\n const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };\n const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };\n\n beforeEach(() => {\n authService = createMockAuthService();\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2,\n authService as unknown as AuthService\n );\n });\n\n it('org slug and user are passed through to proxy service', async () => {\n authService.validateMcpToken.mockResolvedValue(userA);\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'user-a-profile',\n 'org-a',\n mcpRequest,\n 'user-a'\n );\n });\n\n it('different users get different userId passed to proxy', async () => {\n // First request from User A\n authService.validateMcpToken.mockResolvedValue(userA);\n const reqA = createMockRequest({ authorization: 'Bearer token-a' });\n const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);\n\n // Second request from User B\n authService.validateMcpToken.mockResolvedValue(userB);\n const reqB = createMockRequest({ authorization: 'Bearer token-b' });\n\n await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 1,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-a'\n );\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 2,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-b'\n );\n });\n\n it('gateway endpoint uses default profile with user scoping', async () => {\n authService.validateMcpToken.mockResolvedValue(userA);\n settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleGatewayRequest(req, mcpRequest);\n\n expect(proxyService.handleRequest).toHaveBeenCalledWith('my-default', mcpRequest, 'user-a');\n });\n\n it('gateway wraps NotFoundException with descriptive message', async () => {\n authService.validateMcpToken.mockResolvedValue(userA);\n settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');\n proxyService.handleRequest.mockRejectedValue(\n new NotFoundException('Profile \"missing-profile\" not found')\n );\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await expect(controller.handleGatewayRequest(req, mcpRequest)).rejects.toThrow(\n NotFoundException\n );\n });\n\n it('org-scoped profile info endpoint uses orgSlug', async () => {\n await controller.getOrgProfileInfo('my-org', 'my-profile');\n\n expect(proxyService.getProfileInfoByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org');\n });\n });\n});\n"],"names":["NotFoundException","UnauthorizedException","beforeEach","describe","expect","it","vi","ProxyController","createMockAuthService","getAuth","fn","getSession","mockResolvedValue","validateMcpToken","getUserOrganizations","onModuleInit","createMockProxyService","handleRequest","jsonrpc","id","result","tools","handleRequestByOrgSlug","getProfileInfo","serverStatus","total","connected","servers","getProfileInfoByOrgSlug","getToolsForServer","createMockSettingsService","getDefaultGatewayProfile","getSetting","setSetting","createMockEventEmitter","emit","on","off","createMockRequest","headers","controller","authService","proxyService","settingsService","eventEmitter","req","mcpRequest","method","handleOrgMcpRequest","toHaveBeenCalledWith","handleGatewayRequest","authorization","not","toHaveBeenCalled","validUser","name","email","rejects","toThrow","userA","userB","reqA","mcpReq","reqB","toHaveBeenNthCalledWith","mockRejectedValue","getOrgProfileInfo"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/__tests__/integration/proxy-auth.test.ts"],"sourcesContent":["/**\n * Integration Tests: Proxy controller auth\n *\n * Verifies:\n * - McpOAuthGuard always enforces Bearer token\n * - WWW-Authenticate header with resource_metadata_uri on 401\n * - Valid tokens resolve user and pass through\n * - Org-scoped profile lookup through the proxy service\n * - Gateway endpoint uses default profile with user scoping\n */\n\nimport { NotFoundException, UnauthorizedException } from '@nestjs/common';\nimport type { EventEmitter2 } from '@nestjs/event-emitter';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { type AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/proxy.service.js';\nimport type { SettingsService } from '../../modules/settings/settings.service.js';\n\n// Dynamic import to handle ESM\nconst { McpOAuthGuard } = await import('../../modules/auth/mcp-oauth.guard.js');\nconst { ProxyController } = await import('../../modules/proxy/proxy.controller.js');\n\n// ────────────────────────────────────────────────\n// Mock factories\n// ────────────────────────────────────────────────\n\nfunction createMockAuthService() {\n return {\n getAuth: vi.fn(),\n getSession: vi.fn().mockResolvedValue(null),\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getUserOrganizations: vi.fn().mockResolvedValue([]),\n onModuleInit: vi.fn(),\n };\n}\n\nfunction createMockConfigService(overrides: Record<string, unknown> = {}) {\n const defaults: Record<string, unknown> = {\n 'app.port': 3001,\n BETTER_AUTH_URL: 'http://localhost:3001',\n };\n const config = { ...defaults, ...overrides };\n return {\n get: vi.fn((key: string) => config[key]),\n };\n}\n\nfunction createMockProxyService() {\n return {\n handleRequest: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n handleRequestByOrgSlug: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n getProfileInfo: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getProfileInfoByOrgSlug: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getToolsForServer: vi.fn().mockResolvedValue([]),\n };\n}\n\nfunction createMockSettingsService() {\n return {\n getDefaultGatewayProfile: vi.fn().mockResolvedValue('default'),\n getSetting: vi.fn(),\n setSetting: vi.fn(),\n };\n}\n\nfunction createMockEventEmitter() {\n return {\n emit: vi.fn(),\n on: vi.fn(),\n off: vi.fn(),\n };\n}\n\nfunction createMockRequest(headers: Record<string, string> = {}, query: Record<string, string> = {}) {\n return {\n headers,\n query,\n on: vi.fn(),\n user: undefined as any,\n } as unknown as import('express').Request;\n}\n\nfunction createMockExecutionContext(req: import('express').Request) {\n return {\n switchToHttp: () => ({\n getRequest: () => req,\n getResponse: () => ({}),\n }),\n getHandler: () => ({}),\n getClass: () => ({}),\n } as any;\n}\n\n// ────────────────────────────────────────────────\n// McpOAuthGuard tests\n// ────────────────────────────────────────────────\n\ndescribe('McpOAuthGuard', () => {\n let guard: InstanceType<typeof McpOAuthGuard>;\n let authService: ReturnType<typeof createMockAuthService>;\n\n beforeEach(() => {\n authService = createMockAuthService();\n const configService = createMockConfigService();\n guard = new McpOAuthGuard(\n authService as unknown as AuthService,\n configService as any\n );\n });\n\n it('rejects request without Bearer token with 401', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('returns WWW-Authenticate header on missing token', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n try {\n await guard.canActivate(ctx);\n } catch (error: any) {\n expect(error.wwwAuthenticate).toContain('resource_metadata=');\n expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');\n expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');\n }\n });\n\n it('rejects invalid Bearer token', async () => {\n authService.validateMcpToken.mockResolvedValue(null);\n const req = createMockRequest({ authorization: 'Bearer bad-token' });\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');\n });\n\n it('allows valid Bearer token and attaches user', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({ authorization: 'Bearer valid-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(req.user).toEqual(user);\n });\n\n it('accepts access_token query param (SSE fallback)', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({}, { access_token: 'sse-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n});\n\n// ────────────────────────────────────────────────\n// Proxy controller auth tests\n// ────────────────────────────────────────────────\n\ndescribe('Proxy controller auth', () => {\n let controller: InstanceType<typeof ProxyController>;\n let proxyService: ReturnType<typeof createMockProxyService>;\n let settingsService: ReturnType<typeof createMockSettingsService>;\n let eventEmitter: ReturnType<typeof createMockEventEmitter>;\n\n beforeEach(() => {\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2\n );\n });\n\n describe('guard-authenticated user passthrough', () => {\n it('uses req.user set by McpOAuthGuard', async () => {\n const guardUser: AuthUser = { id: 'guard-user', name: 'Guard', email: 'g@test.com' };\n const req = createMockRequest({ authorization: 'Bearer some-token' });\n (req as any).user = guardUser;\n\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n 'guard-user'\n );\n });\n });\n\n describe('org-scoped profile lookup', () => {\n const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };\n const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };\n\n it('org slug and user are passed through to proxy service', async () => {\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'user-a-profile',\n 'org-a',\n mcpRequest,\n 'user-a'\n );\n });\n\n it('different users get different userId passed to proxy', async () => {\n const reqA = createMockRequest({ authorization: 'Bearer token-a' });\n (reqA as any).user = userA;\n const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);\n\n const reqB = createMockRequest({ authorization: 'Bearer token-b' });\n (reqB as any).user = userB;\n\n await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 1,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-a'\n );\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 2,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-b'\n );\n });\n\n it('gateway endpoint uses default profile with user scoping', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleGatewayRequest(req, mcpRequest);\n\n expect(proxyService.handleRequest).toHaveBeenCalledWith('my-default', mcpRequest, 'user-a');\n });\n\n it('gateway wraps NotFoundException with descriptive message', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');\n proxyService.handleRequest.mockRejectedValue(\n new NotFoundException('Profile \"missing-profile\" not found')\n );\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await expect(controller.handleGatewayRequest(req, mcpRequest)).rejects.toThrow(\n NotFoundException\n );\n });\n\n it('org-scoped profile info endpoint uses orgSlug', async () => {\n await controller.getOrgProfileInfo('my-org', 'my-profile');\n\n expect(proxyService.getProfileInfoByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org');\n });\n });\n});\n"],"names":["NotFoundException","UnauthorizedException","beforeEach","describe","expect","it","vi","McpOAuthGuard","ProxyController","createMockAuthService","getAuth","fn","getSession","mockResolvedValue","validateMcpToken","getUserOrganizations","onModuleInit","createMockConfigService","overrides","defaults","BETTER_AUTH_URL","config","get","key","createMockProxyService","handleRequest","jsonrpc","id","result","tools","handleRequestByOrgSlug","getProfileInfo","serverStatus","total","connected","servers","getProfileInfoByOrgSlug","getToolsForServer","createMockSettingsService","getDefaultGatewayProfile","getSetting","setSetting","createMockEventEmitter","emit","on","off","createMockRequest","headers","query","user","undefined","createMockExecutionContext","req","switchToHttp","getRequest","getResponse","getHandler","getClass","guard","authService","configService","ctx","canActivate","rejects","toThrow","error","wwwAuthenticate","toContain","authorization","toHaveBeenCalledWith","name","email","toBe","toEqual","access_token","controller","proxyService","settingsService","eventEmitter","guardUser","mcpRequest","method","handleOrgMcpRequest","userA","userB","reqA","mcpReq","reqB","toHaveBeenNthCalledWith","handleGatewayRequest","mockRejectedValue","getOrgProfileInfo"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for AuthGuard
|
|
3
|
-
*/ import { UnauthorizedException } from "@nestjs/common";
|
|
3
|
+
*/ import { ForbiddenException, UnauthorizedException } from "@nestjs/common";
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { AuthGuard } from "../../modules/auth/auth.guard.js";
|
|
6
6
|
function createMockExecutionContext(headers = {}) {
|
|
@@ -54,7 +54,7 @@ describe('AuthGuard', ()=>{
|
|
|
54
54
|
const mockSession = {
|
|
55
55
|
id: 'sess-1',
|
|
56
56
|
userId: 'user-1',
|
|
57
|
-
activeOrganizationId:
|
|
57
|
+
activeOrganizationId: 'org-1'
|
|
58
58
|
};
|
|
59
59
|
authService.getSession.mockResolvedValue({
|
|
60
60
|
user: mockUser,
|
|
@@ -69,6 +69,27 @@ describe('AuthGuard', ()=>{
|
|
|
69
69
|
expect(request.user).toEqual(mockUser);
|
|
70
70
|
expect(request.sessionData).toEqual(mockSession);
|
|
71
71
|
});
|
|
72
|
+
it('should throw ForbiddenException when session has no active organization', async ()=>{
|
|
73
|
+
const mockUser = {
|
|
74
|
+
id: 'user-1',
|
|
75
|
+
name: 'Test',
|
|
76
|
+
email: 'test@example.com',
|
|
77
|
+
image: null
|
|
78
|
+
};
|
|
79
|
+
const mockSession = {
|
|
80
|
+
id: 'sess-1',
|
|
81
|
+
userId: 'user-1',
|
|
82
|
+
activeOrganizationId: null
|
|
83
|
+
};
|
|
84
|
+
authService.getSession.mockResolvedValue({
|
|
85
|
+
user: mockUser,
|
|
86
|
+
session: mockSession
|
|
87
|
+
});
|
|
88
|
+
const ctx = createMockExecutionContext({
|
|
89
|
+
cookie: 'session=abc'
|
|
90
|
+
});
|
|
91
|
+
await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
|
|
92
|
+
});
|
|
72
93
|
});
|
|
73
94
|
|
|
74
95
|
//# sourceMappingURL=auth.guard.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/__tests__/unit/auth.guard.test.ts"],"sourcesContent":["/**\n * Tests for AuthGuard\n */\n\nimport { ExecutionContext, UnauthorizedException } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AuthGuard } from '../../modules/auth/auth.guard.js';\nimport { AuthService } from '../../modules/auth/auth.service.js';\n\nfunction createMockExecutionContext(headers: Record<string, string> = {}): ExecutionContext {\n const request = { headers, user: undefined, sessionData: undefined };\n return {\n switchToHttp: () => ({\n getRequest: () => request,\n }),\n getHandler: () => ({}),\n getClass: () => ({}),\n } as unknown as ExecutionContext;\n}\n\ndescribe('AuthGuard', () => {\n let guard: AuthGuard;\n let authService: { getSession: ReturnType<typeof vi.fn> };\n let reflector: { getAllAndOverride: ReturnType<typeof vi.fn> };\n\n beforeEach(() => {\n authService = {\n getSession: vi.fn(),\n };\n reflector = {\n getAllAndOverride: vi.fn().mockReturnValue(false),\n };\n guard = new AuthGuard(authService as unknown as AuthService, reflector as unknown as Reflector);\n });\n\n it('should allow @Public() routes without auth', async () => {\n reflector.getAllAndOverride.mockReturnValue(true);\n const ctx = createMockExecutionContext();\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(authService.getSession).not.toHaveBeenCalled();\n });\n\n it('should throw UnauthorizedException when no session', async () => {\n authService.getSession.mockResolvedValue(null);\n const ctx = createMockExecutionContext({ cookie: 'session=abc' });\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('should attach user and session when valid session exists', async () => {\n const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };\n const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId:
|
|
1
|
+
{"version":3,"sources":["../../../src/__tests__/unit/auth.guard.test.ts"],"sourcesContent":["/**\n * Tests for AuthGuard\n */\n\nimport { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AuthGuard } from '../../modules/auth/auth.guard.js';\nimport { AuthService } from '../../modules/auth/auth.service.js';\n\nfunction createMockExecutionContext(headers: Record<string, string> = {}): ExecutionContext {\n const request = { headers, user: undefined, sessionData: undefined };\n return {\n switchToHttp: () => ({\n getRequest: () => request,\n }),\n getHandler: () => ({}),\n getClass: () => ({}),\n } as unknown as ExecutionContext;\n}\n\ndescribe('AuthGuard', () => {\n let guard: AuthGuard;\n let authService: { getSession: ReturnType<typeof vi.fn> };\n let reflector: { getAllAndOverride: ReturnType<typeof vi.fn> };\n\n beforeEach(() => {\n authService = {\n getSession: vi.fn(),\n };\n reflector = {\n getAllAndOverride: vi.fn().mockReturnValue(false),\n };\n guard = new AuthGuard(authService as unknown as AuthService, reflector as unknown as Reflector);\n });\n\n it('should allow @Public() routes without auth', async () => {\n reflector.getAllAndOverride.mockReturnValue(true);\n const ctx = createMockExecutionContext();\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(authService.getSession).not.toHaveBeenCalled();\n });\n\n it('should throw UnauthorizedException when no session', async () => {\n authService.getSession.mockResolvedValue(null);\n const ctx = createMockExecutionContext({ cookie: 'session=abc' });\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('should attach user and session when valid session exists', async () => {\n const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };\n const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: 'org-1' };\n authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });\n\n const ctx = createMockExecutionContext({ cookie: 'session=abc' });\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n const request = ctx.switchToHttp().getRequest();\n expect(request.user).toEqual(mockUser);\n expect(request.sessionData).toEqual(mockSession);\n });\n\n it('should throw ForbiddenException when session has no active organization', async () => {\n const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };\n const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: null };\n authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });\n\n const ctx = createMockExecutionContext({ cookie: 'session=abc' });\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);\n });\n});\n"],"names":["ForbiddenException","UnauthorizedException","beforeEach","describe","expect","it","vi","AuthGuard","createMockExecutionContext","headers","request","user","undefined","sessionData","switchToHttp","getRequest","getHandler","getClass","guard","authService","reflector","getSession","fn","getAllAndOverride","mockReturnValue","ctx","result","canActivate","toBe","not","toHaveBeenCalled","mockResolvedValue","cookie","rejects","toThrow","mockUser","id","name","email","image","mockSession","userId","activeOrganizationId","session","toEqual"],"mappings":"AAAA;;CAEC,GAED,SAA2BA,kBAAkB,EAAEC,qBAAqB,QAAQ,iBAAiB;AAE7F,SAASC,UAAU,EAAEC,QAAQ,EAAEC,MAAM,EAAEC,EAAE,EAAEC,EAAE,QAAQ,SAAS;AAC9D,SAASC,SAAS,QAAQ,mCAAmC;AAG7D,SAASC,2BAA2BC,UAAkC,CAAC,CAAC;IACtE,MAAMC,UAAU;QAAED;QAASE,MAAMC;QAAWC,aAAaD;IAAU;IACnE,OAAO;QACLE,cAAc,IAAO,CAAA;gBACnBC,YAAY,IAAML;YACpB,CAAA;QACAM,YAAY,IAAO,CAAA,CAAC,CAAA;QACpBC,UAAU,IAAO,CAAA,CAAC,CAAA;IACpB;AACF;AAEAd,SAAS,aAAa;IACpB,IAAIe;IACJ,IAAIC;IACJ,IAAIC;IAEJlB,WAAW;QACTiB,cAAc;YACZE,YAAYf,GAAGgB,EAAE;QACnB;QACAF,YAAY;YACVG,mBAAmBjB,GAAGgB,EAAE,GAAGE,eAAe,CAAC;QAC7C;QACAN,QAAQ,IAAIX,UAAUY,aAAuCC;IAC/D;IAEAf,GAAG,8CAA8C;QAC/Ce,UAAUG,iBAAiB,CAACC,eAAe,CAAC;QAC5C,MAAMC,MAAMjB;QACZ,MAAMkB,SAAS,MAAMR,MAAMS,WAAW,CAACF;QAEvCrB,OAAOsB,QAAQE,IAAI,CAAC;QACpBxB,OAAOe,YAAYE,UAAU,EAAEQ,GAAG,CAACC,gBAAgB;IACrD;IAEAzB,GAAG,sDAAsD;QACvDc,YAAYE,UAAU,CAACU,iBAAiB,CAAC;QACzC,MAAMN,MAAMjB,2BAA2B;YAAEwB,QAAQ;QAAc;QAE/D,MAAM5B,OAAOc,MAAMS,WAAW,CAACF,MAAMQ,OAAO,CAACC,OAAO,CAACjC;IACvD;IAEAI,GAAG,4DAA4D;QAC7D,MAAM8B,WAAW;YAAEC,IAAI;YAAUC,MAAM;YAAQC,OAAO;YAAoBC,OAAO;QAAK;QACtF,MAAMC,cAAc;YAAEJ,IAAI;YAAUK,QAAQ;YAAUC,sBAAsB;QAAQ;QACpFvB,YAAYE,UAAU,CAACU,iBAAiB,CAAC;YAAEpB,MAAMwB;YAAUQ,SAASH;QAAY;QAEhF,MAAMf,MAAMjB,2BAA2B;YAAEwB,QAAQ;QAAc;QAC/D,MAAMN,SAAS,MAAMR,MAAMS,WAAW,CAACF;QAEvCrB,OAAOsB,QAAQE,IAAI,CAAC;QACpB,MAAMlB,UAAUe,IAAIX,YAAY,GAAGC,UAAU;QAC7CX,OAAOM,QAAQC,IAAI,EAAEiC,OAAO,CAACT;QAC7B/B,OAAOM,QAAQG,WAAW,EAAE+B,OAAO,CAACJ;IACtC;IAEAnC,GAAG,2EAA2E;QAC5E,MAAM8B,WAAW;YAAEC,IAAI;YAAUC,MAAM;YAAQC,OAAO;YAAoBC,OAAO;QAAK;QACtF,MAAMC,cAAc;YAAEJ,IAAI;YAAUK,QAAQ;YAAUC,sBAAsB;QAAK;QACjFvB,YAAYE,UAAU,CAACU,iBAAiB,CAAC;YAAEpB,MAAMwB;YAAUQ,SAASH;QAAY;QAEhF,MAAMf,MAAMjB,2BAA2B;YAAEwB,QAAQ;QAAc;QAE/D,MAAM5B,OAAOc,MAAMS,WAAW,CAACF,MAAMQ,OAAO,CAACC,OAAO,CAAClC;IACvD;AACF"}
|
|
@@ -48,6 +48,12 @@ export class AllExceptionsFilter {
|
|
|
48
48
|
if (typeof requestId === 'string') {
|
|
49
49
|
errorResponse.requestId = requestId;
|
|
50
50
|
}
|
|
51
|
+
// Set WWW-Authenticate header for MCP OAuth errors (RFC 9728)
|
|
52
|
+
const mcpException = exception instanceof HttpException ? exception : null;
|
|
53
|
+
if (mcpException?.wwwAuthenticate) {
|
|
54
|
+
response.setHeader('WWW-Authenticate', mcpException.wwwAuthenticate);
|
|
55
|
+
response.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate');
|
|
56
|
+
}
|
|
51
57
|
response.status(status).json(errorResponse);
|
|
52
58
|
}
|
|
53
59
|
constructor(){
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/common/filters/all-exceptions.filter.ts"],"sourcesContent":["/**\n * Global Exception Filter\n *\n * Catches all exceptions and formats them consistently.\n */\n\nimport {\n ArgumentsHost,\n Catch,\n ExceptionFilter,\n HttpException,\n HttpStatus,\n Logger,\n} from '@nestjs/common';\nimport { Request, Response } from 'express';\n\ninterface ErrorResponse {\n statusCode: number;\n message: string;\n error: string;\n timestamp: string;\n path: string;\n requestId?: string;\n}\n\n@Catch()\nexport class AllExceptionsFilter implements ExceptionFilter {\n private readonly logger = new Logger(AllExceptionsFilter.name);\n\n catch(exception: unknown, host: ArgumentsHost): void {\n const ctx = host.switchToHttp();\n const response = ctx.getResponse<Response>();\n const request = ctx.getRequest<Request>();\n\n let status = HttpStatus.INTERNAL_SERVER_ERROR;\n let message = 'Internal server error';\n let error = 'Internal Server Error';\n\n if (exception instanceof HttpException) {\n status = exception.getStatus();\n const exceptionResponse = exception.getResponse();\n\n if (typeof exceptionResponse === 'string') {\n message = exceptionResponse;\n } else if (typeof exceptionResponse === 'object') {\n const responseObj = exceptionResponse as Record<string, unknown>;\n message = (responseObj.message as string) || message;\n error = (responseObj.error as string) || exception.name;\n }\n } else if (exception instanceof Error) {\n message = exception.message;\n error = exception.name;\n }\n\n // Log error\n this.logger.error(`${request.method} ${request.url} - ${status} - ${message}`, {\n exception: exception instanceof Error ? exception.stack : String(exception),\n requestId: request.headers['x-request-id'],\n });\n\n const errorResponse: ErrorResponse = {\n statusCode: status,\n message,\n error,\n timestamp: new Date().toISOString(),\n path: request.url,\n };\n\n // Add request ID if present\n const requestId = request.headers['x-request-id'];\n if (typeof requestId === 'string') {\n errorResponse.requestId = requestId;\n }\n\n response.status(status).json(errorResponse);\n }\n}\n"],"names":["Catch","HttpException","HttpStatus","Logger","AllExceptionsFilter","catch","exception","host","ctx","switchToHttp","response","getResponse","request","getRequest","status","INTERNAL_SERVER_ERROR","message","error","getStatus","exceptionResponse","responseObj","name","Error","logger","method","url","stack","String","requestId","headers","errorResponse","statusCode","timestamp","Date","toISOString","path","json"],"mappings":";;;;;;AAAA;;;;CAIC,GAED,SAEEA,KAAK,EAELC,aAAa,EACbC,UAAU,EACVC,MAAM,QACD,iBAAiB;
|
|
1
|
+
{"version":3,"sources":["../../../src/common/filters/all-exceptions.filter.ts"],"sourcesContent":["/**\n * Global Exception Filter\n *\n * Catches all exceptions and formats them consistently.\n */\n\nimport {\n ArgumentsHost,\n Catch,\n ExceptionFilter,\n HttpException,\n HttpStatus,\n Logger,\n} from '@nestjs/common';\nimport { Request, Response } from 'express';\n\ninterface ErrorResponse {\n statusCode: number;\n message: string;\n error: string;\n timestamp: string;\n path: string;\n requestId?: string;\n}\n\ntype McpHttpException = HttpException & {\n wwwAuthenticate?: string;\n};\n\n@Catch()\nexport class AllExceptionsFilter implements ExceptionFilter {\n private readonly logger = new Logger(AllExceptionsFilter.name);\n\n catch(exception: unknown, host: ArgumentsHost): void {\n const ctx = host.switchToHttp();\n const response = ctx.getResponse<Response>();\n const request = ctx.getRequest<Request>();\n\n let status = HttpStatus.INTERNAL_SERVER_ERROR;\n let message = 'Internal server error';\n let error = 'Internal Server Error';\n\n if (exception instanceof HttpException) {\n status = exception.getStatus();\n const exceptionResponse = exception.getResponse();\n\n if (typeof exceptionResponse === 'string') {\n message = exceptionResponse;\n } else if (typeof exceptionResponse === 'object') {\n const responseObj = exceptionResponse as Record<string, unknown>;\n message = (responseObj.message as string) || message;\n error = (responseObj.error as string) || exception.name;\n }\n } else if (exception instanceof Error) {\n message = exception.message;\n error = exception.name;\n }\n\n // Log error\n this.logger.error(`${request.method} ${request.url} - ${status} - ${message}`, {\n exception: exception instanceof Error ? exception.stack : String(exception),\n requestId: request.headers['x-request-id'],\n });\n\n const errorResponse: ErrorResponse = {\n statusCode: status,\n message,\n error,\n timestamp: new Date().toISOString(),\n path: request.url,\n };\n\n // Add request ID if present\n const requestId = request.headers['x-request-id'];\n if (typeof requestId === 'string') {\n errorResponse.requestId = requestId;\n }\n\n // Set WWW-Authenticate header for MCP OAuth errors (RFC 9728)\n const mcpException = exception instanceof HttpException ? (exception as McpHttpException) : null;\n if (mcpException?.wwwAuthenticate) {\n response.setHeader('WWW-Authenticate', mcpException.wwwAuthenticate);\n response.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate');\n }\n\n response.status(status).json(errorResponse);\n }\n}\n"],"names":["Catch","HttpException","HttpStatus","Logger","AllExceptionsFilter","catch","exception","host","ctx","switchToHttp","response","getResponse","request","getRequest","status","INTERNAL_SERVER_ERROR","message","error","getStatus","exceptionResponse","responseObj","name","Error","logger","method","url","stack","String","requestId","headers","errorResponse","statusCode","timestamp","Date","toISOString","path","mcpException","wwwAuthenticate","setHeader","json"],"mappings":";;;;;;AAAA;;;;CAIC,GAED,SAEEA,KAAK,EAELC,aAAa,EACbC,UAAU,EACVC,MAAM,QACD,iBAAiB;AAiBxB,OAAO,MAAMC;IAGXC,MAAMC,SAAkB,EAAEC,IAAmB,EAAQ;QACnD,MAAMC,MAAMD,KAAKE,YAAY;QAC7B,MAAMC,WAAWF,IAAIG,WAAW;QAChC,MAAMC,UAAUJ,IAAIK,UAAU;QAE9B,IAAIC,SAASZ,WAAWa,qBAAqB;QAC7C,IAAIC,UAAU;QACd,IAAIC,QAAQ;QAEZ,IAAIX,qBAAqBL,eAAe;YACtCa,SAASR,UAAUY,SAAS;YAC5B,MAAMC,oBAAoBb,UAAUK,WAAW;YAE/C,IAAI,OAAOQ,sBAAsB,UAAU;gBACzCH,UAAUG;YACZ,OAAO,IAAI,OAAOA,sBAAsB,UAAU;gBAChD,MAAMC,cAAcD;gBACpBH,UAAU,AAACI,YAAYJ,OAAO,IAAeA;gBAC7CC,QAAQ,AAACG,YAAYH,KAAK,IAAeX,UAAUe,IAAI;YACzD;QACF,OAAO,IAAIf,qBAAqBgB,OAAO;YACrCN,UAAUV,UAAUU,OAAO;YAC3BC,QAAQX,UAAUe,IAAI;QACxB;QAEA,YAAY;QACZ,IAAI,CAACE,MAAM,CAACN,KAAK,CAAC,GAAGL,QAAQY,MAAM,CAAC,CAAC,EAAEZ,QAAQa,GAAG,CAAC,GAAG,EAAEX,OAAO,GAAG,EAAEE,SAAS,EAAE;YAC7EV,WAAWA,qBAAqBgB,QAAQhB,UAAUoB,KAAK,GAAGC,OAAOrB;YACjEsB,WAAWhB,QAAQiB,OAAO,CAAC,eAAe;QAC5C;QAEA,MAAMC,gBAA+B;YACnCC,YAAYjB;YACZE;YACAC;YACAe,WAAW,IAAIC,OAAOC,WAAW;YACjCC,MAAMvB,QAAQa,GAAG;QACnB;QAEA,4BAA4B;QAC5B,MAAMG,YAAYhB,QAAQiB,OAAO,CAAC,eAAe;QACjD,IAAI,OAAOD,cAAc,UAAU;YACjCE,cAAcF,SAAS,GAAGA;QAC5B;QAEA,8DAA8D;QAC9D,MAAMQ,eAAe9B,qBAAqBL,gBAAiBK,YAAiC;QAC5F,IAAI8B,cAAcC,iBAAiB;YACjC3B,SAAS4B,SAAS,CAAC,oBAAoBF,aAAaC,eAAe;YACnE3B,SAAS4B,SAAS,CAAC,iCAAiC;QACtD;QAEA5B,SAASI,MAAM,CAACA,QAAQyB,IAAI,CAACT;IAC/B;;aAvDiBP,SAAS,IAAIpB,OAAOC,oBAAoBiB,IAAI;;AAwD/D"}
|
package/dist/main.js
CHANGED
|
@@ -13,6 +13,7 @@ import { AppModule } from "./app.module.js";
|
|
|
13
13
|
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter.js";
|
|
14
14
|
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor.js";
|
|
15
15
|
import { AuthService } from "./modules/auth/auth.service.js";
|
|
16
|
+
import { createMcpProtectedResourceMetadata, resolvePublicAuthBaseUrl, resolvePublicBackendOrigin } from "./modules/auth/mcp-oauth.utils.js";
|
|
16
17
|
async function bootstrap() {
|
|
17
18
|
// Determine log levels from environment
|
|
18
19
|
const logLevel = process.env.LOG_LEVEL || 'log';
|
|
@@ -85,6 +86,42 @@ async function bootstrap() {
|
|
|
85
86
|
if (!auth) return next();
|
|
86
87
|
toNodeHandler(auth)(req, res);
|
|
87
88
|
};
|
|
89
|
+
expressApp.get('/.well-known/oauth-protected-resource', (_req, res)=>{
|
|
90
|
+
res.json(createMcpProtectedResourceMetadata(configService));
|
|
91
|
+
});
|
|
92
|
+
// RFC 8414 – OAuth 2.0 Authorization Server Metadata
|
|
93
|
+
// MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)
|
|
94
|
+
// instead of falling back to the root /register which returns 404.
|
|
95
|
+
expressApp.get('/.well-known/oauth-authorization-server', (_req, res)=>{
|
|
96
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
97
|
+
const authBaseUrl = resolvePublicAuthBaseUrl(configService);
|
|
98
|
+
res.json({
|
|
99
|
+
issuer: backendOrigin,
|
|
100
|
+
authorization_endpoint: `${authBaseUrl}/mcp/authorize`,
|
|
101
|
+
token_endpoint: `${authBaseUrl}/mcp/token`,
|
|
102
|
+
registration_endpoint: `${authBaseUrl}/mcp/register`,
|
|
103
|
+
jwks_uri: `${authBaseUrl}/mcp/jwks`,
|
|
104
|
+
response_types_supported: [
|
|
105
|
+
'code'
|
|
106
|
+
],
|
|
107
|
+
grant_types_supported: [
|
|
108
|
+
'authorization_code',
|
|
109
|
+
'refresh_token'
|
|
110
|
+
],
|
|
111
|
+
token_endpoint_auth_methods_supported: [
|
|
112
|
+
'none'
|
|
113
|
+
],
|
|
114
|
+
code_challenge_methods_supported: [
|
|
115
|
+
'S256'
|
|
116
|
+
],
|
|
117
|
+
scopes_supported: [
|
|
118
|
+
'openid',
|
|
119
|
+
'profile',
|
|
120
|
+
'email',
|
|
121
|
+
'offline_access'
|
|
122
|
+
]
|
|
123
|
+
});
|
|
124
|
+
});
|
|
88
125
|
expressApp.all('/api/auth/*splat', lazyAuthHandler);
|
|
89
126
|
expressApp.all('/.well-known/*splat', lazyAuthHandler);
|
|
90
127
|
logger.log('Better Auth routes registered (lazy) on /api/auth/* and /.well-known/*');
|