@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,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,145 @@ 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
|
});
|
|
177
|
+
});
|
|
165
178
|
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
});
|
|
179
|
+
// ────────────────────────────────────────────────
|
|
180
|
+
// Proxy controller auth tests
|
|
181
|
+
// ────────────────────────────────────────────────
|
|
190
182
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const mcpRequest: McpRequest = {
|
|
197
|
-
jsonrpc: '2.0',
|
|
198
|
-
id: 1,
|
|
199
|
-
method: 'tools/list',
|
|
200
|
-
};
|
|
183
|
+
describe('Proxy controller auth', () => {
|
|
184
|
+
let controller: InstanceType<typeof ProxyController>;
|
|
185
|
+
let proxyService: ReturnType<typeof createMockProxyService>;
|
|
186
|
+
let settingsService: ReturnType<typeof createMockSettingsService>;
|
|
187
|
+
let eventEmitter: ReturnType<typeof createMockEventEmitter>;
|
|
201
188
|
|
|
202
|
-
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
proxyService = createMockProxyService();
|
|
191
|
+
settingsService = createMockSettingsService();
|
|
192
|
+
eventEmitter = createMockEventEmitter();
|
|
203
193
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
);
|
|
211
|
-
});
|
|
194
|
+
controller = new ProxyController(
|
|
195
|
+
proxyService as unknown as ProxyService,
|
|
196
|
+
settingsService as unknown as SettingsService,
|
|
197
|
+
eventEmitter as unknown as EventEmitter2
|
|
198
|
+
);
|
|
199
|
+
});
|
|
212
200
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
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
|
-
});
|
|
201
|
+
describe('guard-authenticated user passthrough', () => {
|
|
202
|
+
it('uses req.user set by McpOAuthGuard', async () => {
|
|
203
|
+
const guardUser: AuthUser = { id: 'guard-user', name: 'Guard', email: 'g@test.com' };
|
|
204
|
+
const req = createMockRequest({ authorization: 'Bearer some-token' });
|
|
205
|
+
(req as any).user = guardUser;
|
|
228
206
|
|
|
229
|
-
|
|
230
|
-
const req = createMockRequest();
|
|
231
|
-
const mcpRequest: McpRequest = {
|
|
232
|
-
jsonrpc: '2.0',
|
|
233
|
-
id: 1,
|
|
234
|
-
method: 'tools/list',
|
|
235
|
-
};
|
|
207
|
+
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
236
208
|
|
|
237
209
|
await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
|
|
238
210
|
|
|
239
|
-
expect(authService.validateMcpToken).not.toHaveBeenCalled();
|
|
240
211
|
expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
|
|
241
212
|
'my-profile',
|
|
242
213
|
'my-org',
|
|
243
214
|
mcpRequest,
|
|
244
|
-
'
|
|
215
|
+
'guard-user'
|
|
245
216
|
);
|
|
246
217
|
});
|
|
247
218
|
});
|
|
248
219
|
|
|
249
|
-
// ────────────────────────────────────────────────
|
|
250
|
-
// Org-scoped profile lookup through proxy
|
|
251
|
-
// ────────────────────────────────────────────────
|
|
252
|
-
|
|
253
220
|
describe('org-scoped profile lookup', () => {
|
|
254
221
|
const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };
|
|
255
222
|
const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };
|
|
256
223
|
|
|
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
224
|
it('org slug and user are passed through to proxy service', async () => {
|
|
272
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
273
225
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
226
|
+
(req as any).user = userA;
|
|
274
227
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
275
228
|
|
|
276
229
|
await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);
|
|
@@ -284,16 +237,14 @@ describe('Proxy controller auth', () => {
|
|
|
284
237
|
});
|
|
285
238
|
|
|
286
239
|
it('different users get different userId passed to proxy', async () => {
|
|
287
|
-
// First request from User A
|
|
288
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
289
240
|
const reqA = createMockRequest({ authorization: 'Bearer token-a' });
|
|
241
|
+
(reqA as any).user = userA;
|
|
290
242
|
const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
291
243
|
|
|
292
244
|
await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);
|
|
293
245
|
|
|
294
|
-
// Second request from User B
|
|
295
|
-
authService.validateMcpToken.mockResolvedValue(userB);
|
|
296
246
|
const reqB = createMockRequest({ authorization: 'Bearer token-b' });
|
|
247
|
+
(reqB as any).user = userB;
|
|
297
248
|
|
|
298
249
|
await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);
|
|
299
250
|
|
|
@@ -314,10 +265,10 @@ describe('Proxy controller auth', () => {
|
|
|
314
265
|
});
|
|
315
266
|
|
|
316
267
|
it('gateway endpoint uses default profile with user scoping', async () => {
|
|
317
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
318
268
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');
|
|
319
269
|
|
|
320
270
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
271
|
+
(req as any).user = userA;
|
|
321
272
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
322
273
|
|
|
323
274
|
await controller.handleGatewayRequest(req, mcpRequest);
|
|
@@ -326,13 +277,13 @@ describe('Proxy controller auth', () => {
|
|
|
326
277
|
});
|
|
327
278
|
|
|
328
279
|
it('gateway wraps NotFoundException with descriptive message', async () => {
|
|
329
|
-
authService.validateMcpToken.mockResolvedValue(userA);
|
|
330
280
|
settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');
|
|
331
281
|
proxyService.handleRequest.mockRejectedValue(
|
|
332
282
|
new NotFoundException('Profile "missing-profile" not found')
|
|
333
283
|
);
|
|
334
284
|
|
|
335
285
|
const req = createMockRequest({ authorization: 'Bearer token-a' });
|
|
286
|
+
(req as any).user = userA;
|
|
336
287
|
const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };
|
|
337
288
|
|
|
338
289
|
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
|
}
|
package/src/main.ts
CHANGED
|
@@ -10,11 +10,17 @@ import { ConfigService } from '@nestjs/config';
|
|
|
10
10
|
import { NestFactory } from '@nestjs/core';
|
|
11
11
|
import { toNodeHandler } from 'better-auth/node';
|
|
12
12
|
import compression from 'compression';
|
|
13
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
13
14
|
import helmet from 'helmet';
|
|
14
15
|
import { AppModule } from './app.module.js';
|
|
15
16
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';
|
|
16
17
|
import { LoggingInterceptor } from './common/interceptors/logging.interceptor.js';
|
|
17
18
|
import { AuthService } from './modules/auth/auth.service.js';
|
|
19
|
+
import {
|
|
20
|
+
createMcpProtectedResourceMetadata,
|
|
21
|
+
resolvePublicAuthBaseUrl,
|
|
22
|
+
resolvePublicBackendOrigin,
|
|
23
|
+
} from './modules/auth/mcp-oauth.utils.js';
|
|
18
24
|
|
|
19
25
|
async function bootstrap() {
|
|
20
26
|
// Determine log levels from environment
|
|
@@ -72,12 +78,37 @@ async function bootstrap() {
|
|
|
72
78
|
const authService = app.get(AuthService);
|
|
73
79
|
const expressApp = app.getHttpAdapter().getInstance();
|
|
74
80
|
|
|
75
|
-
const lazyAuthHandler = (req:
|
|
81
|
+
const lazyAuthHandler = (req: Request, res: Response, next: NextFunction) => {
|
|
76
82
|
const auth = authService.getAuth();
|
|
77
83
|
if (!auth) return next();
|
|
78
84
|
toNodeHandler(auth)(req, res);
|
|
79
85
|
};
|
|
80
86
|
|
|
87
|
+
expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
|
88
|
+
res.json(createMcpProtectedResourceMetadata(configService));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// RFC 8414 – OAuth 2.0 Authorization Server Metadata
|
|
92
|
+
// MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)
|
|
93
|
+
// instead of falling back to the root /register which returns 404.
|
|
94
|
+
expressApp.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
|
|
95
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
96
|
+
const authBaseUrl = resolvePublicAuthBaseUrl(configService);
|
|
97
|
+
|
|
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: ['code'],
|
|
105
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
106
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
107
|
+
code_challenge_methods_supported: ['S256'],
|
|
108
|
+
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
81
112
|
expressApp.all('/api/auth/*splat', lazyAuthHandler);
|
|
82
113
|
expressApp.all('/.well-known/*splat', lazyAuthHandler);
|
|
83
114
|
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { PrismaClient } from '@dxheroes/local-mcp-database/generated/prisma';
|
|
10
|
+
import { ConfigService } from '@nestjs/config';
|
|
10
11
|
import { betterAuth } from 'better-auth';
|
|
11
12
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
|
12
13
|
import { mcp } from 'better-auth/plugins';
|
|
13
14
|
import { organization } from 'better-auth/plugins/organization';
|
|
15
|
+
import { resolveMcpLoginPageUrl } from './mcp-oauth.utils.js';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Auth wrapper — simplified interface to avoid exporting Better Auth's deep generic types.
|
|
@@ -37,6 +39,7 @@ function toSlug(name: string): string {
|
|
|
37
39
|
|
|
38
40
|
export function createAuth(prisma: PrismaClient): AuthInstance {
|
|
39
41
|
const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
|
|
42
|
+
const configService = new ConfigService();
|
|
40
43
|
|
|
41
44
|
const auth = betterAuth({
|
|
42
45
|
basePath: '/api/auth',
|
|
@@ -106,7 +109,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
|
|
|
106
109
|
plugins: [
|
|
107
110
|
organization(),
|
|
108
111
|
mcp({
|
|
109
|
-
loginPage:
|
|
112
|
+
loginPage: resolveMcpLoginPageUrl(configService),
|
|
110
113
|
}),
|
|
111
114
|
],
|
|
112
115
|
});
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
import { Global, Module } from '@nestjs/common';
|
|
8
8
|
import { AuthGuard } from './auth.guard.js';
|
|
9
9
|
import { AuthService } from './auth.service.js';
|
|
10
|
+
import { McpOAuthGuard } from './mcp-oauth.guard.js';
|
|
10
11
|
|
|
11
12
|
@Global()
|
|
12
13
|
@Module({
|
|
13
|
-
providers: [AuthService, AuthGuard],
|
|
14
|
-
exports: [AuthService, AuthGuard],
|
|
14
|
+
providers: [AuthService, AuthGuard, McpOAuthGuard],
|
|
15
|
+
exports: [AuthService, AuthGuard, McpOAuthGuard],
|
|
15
16
|
})
|
|
16
17
|
export class AuthModule {}
|
|
@@ -101,13 +101,13 @@ export class AuthService implements OnModuleInit {
|
|
|
101
101
|
async validateMcpToken(bearerToken: string): Promise<AuthUser | null> {
|
|
102
102
|
try {
|
|
103
103
|
const tokenRecord = await this.prisma.oauthAccessToken.findFirst({
|
|
104
|
-
where: {
|
|
104
|
+
where: { accessToken: bearerToken },
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
if (!tokenRecord || !tokenRecord.userId) return null;
|
|
108
108
|
|
|
109
109
|
// Check expiration
|
|
110
|
-
if (
|
|
110
|
+
if (new Date(tokenRecord.accessTokenExpiresAt) < new Date()) {
|
|
111
111
|
return null;
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Guard
|
|
3
|
+
*
|
|
4
|
+
* Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.
|
|
5
|
+
* Validates Bearer tokens and returns RFC 9728-compliant
|
|
6
|
+
* WWW-Authenticate headers to guide MCP clients through OAuth discovery.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CanActivate,
|
|
11
|
+
ExecutionContext,
|
|
12
|
+
Injectable,
|
|
13
|
+
UnauthorizedException,
|
|
14
|
+
} from '@nestjs/common';
|
|
15
|
+
import { ConfigService } from '@nestjs/config';
|
|
16
|
+
import type { Request } from 'express';
|
|
17
|
+
import { AuthService } from './auth.service.js';
|
|
18
|
+
import { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';
|
|
19
|
+
|
|
20
|
+
type McpUnauthorizedException = UnauthorizedException & {
|
|
21
|
+
wwwAuthenticate?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class McpOAuthGuard implements CanActivate {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly authService: AuthService,
|
|
28
|
+
private readonly configService: ConfigService
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
32
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
33
|
+
const token = this.extractToken(request);
|
|
34
|
+
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw this.createUnauthorizedError('Bearer token required');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const user = await this.authService.validateMcpToken(token);
|
|
40
|
+
if (!user) {
|
|
41
|
+
throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
request.user = user;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract Bearer token from Authorization header or access_token query param (SSE fallback).
|
|
50
|
+
*/
|
|
51
|
+
private extractToken(request: Request): string | null {
|
|
52
|
+
const authHeader = request.headers.authorization;
|
|
53
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
54
|
+
return authHeader.slice(7);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SSE connections may pass token as query parameter
|
|
58
|
+
const queryToken = request.query.access_token;
|
|
59
|
+
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
|
60
|
+
return queryToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create UnauthorizedException with RFC 9728 WWW-Authenticate header
|
|
68
|
+
* pointing MCP clients to the protected resource metadata.
|
|
69
|
+
*/
|
|
70
|
+
private createUnauthorizedError(message: string): UnauthorizedException {
|
|
71
|
+
const error = new UnauthorizedException(message) as McpUnauthorizedException;
|
|
72
|
+
error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);
|
|
73
|
+
return error;
|
|
74
|
+
}
|
|
75
|
+
}
|