@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
@@ -1,9 +1,9 @@
1
1
 
2
- > @dxheroes/local-mcp-backend@0.9.2 build /home/runner/work/local-mcp-gateway/local-mcp-gateway/apps/backend
2
+ > @dxheroes/local-mcp-backend@0.11.0 build /home/runner/work/local-mcp-gateway/local-mcp-gateway/apps/backend
3
3
  > nest build
4
4
 
5
5
  -  TSC  Initializing type checker...
6
6
  ✔  TSC  Initializing type checker...
7
7
  >  TSC  Found 0 issues.
8
8
  >  SWC  Running...
9
- Successfully compiled: 56 files with swc (92.29ms)
9
+ Successfully compiled: 60 files with swc (99.01ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.10.0...backend-v0.11.0) (2026-03-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **auth:** enable toggle for email and password authentication ([a0c1dd0](https://github.com/DXHeroes/local-mcp-gateway/commit/a0c1dd0a192ffd4199af2bb00d9e83f1c526be5c))
9
+ * **auth:** enhance MCP OAuth guard to support session cookies ([cc2f0d0](https://github.com/DXHeroes/local-mcp-gateway/commit/cc2f0d092150710691e8258c2fa3d891575e5a76))
10
+ * **dependencies:** add new MCP packages to backend dependencies ([a29fdf4](https://github.com/DXHeroes/local-mcp-gateway/commit/a29fdf4c4af4db0b4df31a9b8a7d7f0fec5a5755))
11
+ * **seo:** enhance SEO and session handling across frontend and backend ([2178043](https://github.com/DXHeroes/local-mcp-gateway/commit/21780436a9e93da63e3c6cec8318b4f8d1a6dbf6))
12
+
13
+
14
+ ### Dependencies
15
+
16
+ * The following workspace dependencies were updated
17
+ * dependencies
18
+ * @dxheroes/local-mcp-core bumped to 0.8.1
19
+ * @dxheroes/local-mcp-database bumped to 0.5.4
20
+ * @dxheroes/mcp-abra-flexi bumped to 0.3.3
21
+ * @dxheroes/mcp-fakturoid bumped to 0.3.3
22
+ * @dxheroes/mcp-gemini-deep-research bumped to 0.5.7
23
+ * @dxheroes/mcp-merk bumped to 0.3.7
24
+ * @dxheroes/mcp-toggl bumped to 0.3.7
25
+ * devDependencies
26
+ * @dxheroes/local-mcp-config bumped to 0.4.12
27
+
28
+ ## [0.10.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.9.2...backend-v0.10.0) (2026-03-12)
29
+
30
+
31
+ ### Features
32
+
33
+ * **auth:** implement MCP OAuth guard and enhance authentication flow ([14f6355](https://github.com/DXHeroes/local-mcp-gateway/commit/14f6355b7e3e1d2a2a757643c7bc1946aa1e09cd))
34
+ * enhance OAuth integration and add custom headers support for MCP servers ([0904743](https://github.com/DXHeroes/local-mcp-gateway/commit/09047436072810f5876a2c92ba47f6f90f8f6103))
35
+
36
+
37
+ ### Code Refactoring
38
+
39
+ * **auth:** streamline MCP OAuth guard and enhance configuration ([38972a3](https://github.com/DXHeroes/local-mcp-gateway/commit/38972a3c94c377d8f5d6c9c714d6ccab91765a99))
40
+ * **database:** update OAuth models and migrations for improved schema ([a92b074](https://github.com/DXHeroes/local-mcp-gateway/commit/a92b07400050b30decdda1dc82f4647d49c38c91))
41
+
42
+
43
+ ### Dependencies
44
+
45
+ * The following workspace dependencies were updated
46
+ * dependencies
47
+ * @dxheroes/local-mcp-core bumped to 0.8.0
48
+ * @dxheroes/local-mcp-database bumped to 0.5.3
49
+ * @dxheroes/mcp-gemini-deep-research bumped to 0.5.6
50
+ * @dxheroes/mcp-merk bumped to 0.3.6
51
+ * @dxheroes/mcp-toggl bumped to 0.3.6
52
+ * devDependencies
53
+ * @dxheroes/local-mcp-config bumped to 0.4.11
54
+
3
55
  ## [0.9.2](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.9.1...backend-v0.9.2) (2026-03-11)
4
56
 
5
57
 
@@ -0,0 +1,283 @@
1
+ import { createServer } from "node:http";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { AllExceptionsFilter } from "../../common/filters/all-exceptions.filter.js";
4
+ import { McpOAuthGuard } from "../../modules/auth/mcp-oauth.guard.js";
5
+ import { createMcpProtectedResourceMetadata, resolvePublicAuthBaseUrl, resolvePublicBackendOrigin } from "../../modules/auth/mcp-oauth.utils.js";
6
+ function createConfigService(values) {
7
+ return {
8
+ get: vi.fn((key)=>values[key])
9
+ };
10
+ }
11
+ function createAuthServiceMock() {
12
+ return {
13
+ validateMcpToken: vi.fn().mockResolvedValue(null),
14
+ getSession: vi.fn().mockResolvedValue(null)
15
+ };
16
+ }
17
+ async function startTestApp(options) {
18
+ const authService = createAuthServiceMock();
19
+ const configService = createConfigService({
20
+ 'app.port': 3001,
21
+ BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001'
22
+ });
23
+ const guard = new McpOAuthGuard(authService, configService);
24
+ const filter = new AllExceptionsFilter();
25
+ const backendOrigin = resolvePublicBackendOrigin(configService);
26
+ const authBaseUrl = resolvePublicAuthBaseUrl(configService);
27
+ const createRequest = (req)=>{
28
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
29
+ return {
30
+ method: req.method ?? 'GET',
31
+ url: `${url.pathname}${url.search}`,
32
+ headers: req.headers,
33
+ query: Object.fromEntries(url.searchParams.entries()),
34
+ user: undefined
35
+ };
36
+ };
37
+ const createResponse = (res)=>{
38
+ const response = {
39
+ setHeader (name, value) {
40
+ res.setHeader(name, value);
41
+ return response;
42
+ },
43
+ status (statusCode) {
44
+ res.statusCode = statusCode;
45
+ return response;
46
+ },
47
+ json (body) {
48
+ res.setHeader('content-type', 'application/json');
49
+ res.end(JSON.stringify(body));
50
+ }
51
+ };
52
+ return response;
53
+ };
54
+ const handleGuardedRoute = async (nodeRequest, nodeResponse, responseBody)=>{
55
+ const request = createRequest(nodeRequest);
56
+ const response = createResponse(nodeResponse);
57
+ const context = {
58
+ switchToHttp: ()=>({
59
+ getRequest: ()=>request,
60
+ getResponse: ()=>response
61
+ })
62
+ };
63
+ const host = {
64
+ switchToHttp: ()=>({
65
+ getRequest: ()=>request,
66
+ getResponse: ()=>response
67
+ })
68
+ };
69
+ try {
70
+ await guard.canActivate(context);
71
+ response.json(responseBody(request));
72
+ } catch (error) {
73
+ filter.catch(error, host);
74
+ }
75
+ };
76
+ const server = await new Promise((resolve)=>{
77
+ const listeningServer = createServer(async (req, res)=>{
78
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
79
+ if (req.method === 'GET' && url.pathname === '/api/mcp/test') {
80
+ await handleGuardedRoute(req, res, (request)=>({
81
+ ok: true,
82
+ userId: request.user?.id ?? null
83
+ }));
84
+ return;
85
+ }
86
+ if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {
87
+ await handleGuardedRoute(req, res, (request)=>({
88
+ ok: true,
89
+ token: request.query.access_token ?? null,
90
+ userId: request.user?.id ?? null
91
+ }));
92
+ return;
93
+ }
94
+ if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
95
+ res.setHeader('content-type', 'application/json');
96
+ res.end(JSON.stringify(createMcpProtectedResourceMetadata(configService)));
97
+ return;
98
+ }
99
+ if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
100
+ res.setHeader('content-type', 'application/json');
101
+ res.end(JSON.stringify({
102
+ issuer: backendOrigin,
103
+ authorization_endpoint: `${authBaseUrl}/mcp/authorize`,
104
+ token_endpoint: `${authBaseUrl}/mcp/token`,
105
+ registration_endpoint: `${authBaseUrl}/mcp/register`,
106
+ jwks_uri: `${authBaseUrl}/mcp/jwks`,
107
+ response_types_supported: [
108
+ 'code'
109
+ ],
110
+ grant_types_supported: [
111
+ 'authorization_code',
112
+ 'refresh_token'
113
+ ],
114
+ code_challenge_methods_supported: [
115
+ 'S256'
116
+ ]
117
+ }));
118
+ return;
119
+ }
120
+ if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {
121
+ res.statusCode = 201;
122
+ res.setHeader('content-type', 'application/json');
123
+ res.end(JSON.stringify({
124
+ client_id: 'cursor-client',
125
+ client_secret: 'cursor-secret',
126
+ redirect_uris: [
127
+ 'https://cursor.sh/callback'
128
+ ]
129
+ }));
130
+ return;
131
+ }
132
+ res.statusCode = 404;
133
+ res.end();
134
+ }).listen(0, ()=>resolve(listeningServer));
135
+ });
136
+ const address = server.address();
137
+ if (!address || typeof address === 'string') {
138
+ throw new Error('Failed to resolve test server address');
139
+ }
140
+ return {
141
+ close: ()=>new Promise((resolve, reject)=>{
142
+ server.close((error)=>{
143
+ if (error) {
144
+ reject(error);
145
+ return;
146
+ }
147
+ resolve();
148
+ });
149
+ }),
150
+ authService,
151
+ baseUrl: `http://127.0.0.1:${address.port}`
152
+ };
153
+ }
154
+ describe('MCP proxy auth HTTP contract', ()=>{
155
+ let close;
156
+ let authService;
157
+ let baseUrl;
158
+ beforeEach(async ()=>{
159
+ const result = await startTestApp({
160
+ backendUrl: 'http://localhost:9631'
161
+ });
162
+ close = result.close;
163
+ authService = result.authService;
164
+ baseUrl = result.baseUrl;
165
+ });
166
+ afterEach(async ()=>{
167
+ if (close) {
168
+ await close();
169
+ }
170
+ });
171
+ it('returns 401 with MCP discovery headers when token is missing', async ()=>{
172
+ const response = await fetch(`${baseUrl}/api/mcp/test`);
173
+ const body = await response.json();
174
+ expect(response.status).toBe(401);
175
+ expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');
176
+ expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');
177
+ expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');
178
+ expect(body.message).toBe('Bearer token required');
179
+ });
180
+ it('serves protected resource metadata with Better Auth-backed URLs', async ()=>{
181
+ const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);
182
+ const body = await response.json();
183
+ expect(response.status).toBe(200);
184
+ expect(body).toEqual({
185
+ resource: 'http://localhost:9631/api/mcp',
186
+ authorization_servers: [
187
+ 'http://localhost:9631'
188
+ ],
189
+ bearer_methods_supported: [
190
+ 'header'
191
+ ],
192
+ scopes_supported: [
193
+ 'openid',
194
+ 'profile',
195
+ 'email',
196
+ 'offline_access'
197
+ ],
198
+ jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',
199
+ resource_signing_alg_values_supported: [
200
+ 'RS256',
201
+ 'none'
202
+ ]
203
+ });
204
+ });
205
+ it('serves authorization server metadata with MCP registration endpoint', async ()=>{
206
+ const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
207
+ const body = await response.json();
208
+ expect(response.status).toBe(200);
209
+ expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');
210
+ expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');
211
+ expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');
212
+ });
213
+ it('exposes the advertised registration endpoint', async ()=>{
214
+ const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {
215
+ method: 'POST',
216
+ headers: {
217
+ 'content-type': 'application/json'
218
+ },
219
+ body: JSON.stringify({
220
+ redirect_uris: [
221
+ 'https://cursor.sh/callback'
222
+ ]
223
+ })
224
+ });
225
+ const body = await response.json();
226
+ expect(response.status).toBe(201);
227
+ expect(body.client_id).toBe('cursor-client');
228
+ });
229
+ it('accepts access_token query params for SSE fallback', async ()=>{
230
+ authService.validateMcpToken.mockResolvedValue({
231
+ id: 'user-sse',
232
+ name: 'SSE User',
233
+ email: 'sse@example.com'
234
+ });
235
+ const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);
236
+ const body = await response.json();
237
+ expect(response.status).toBe(200);
238
+ expect(body).toEqual({
239
+ ok: true,
240
+ token: 'sse-token',
241
+ userId: 'user-sse'
242
+ });
243
+ expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
244
+ });
245
+ it('accepts session cookie when no Bearer token is present', async ()=>{
246
+ authService.getSession.mockResolvedValue({
247
+ user: {
248
+ id: 'cookie-user',
249
+ name: 'Cookie User',
250
+ email: 'cookie@example.com'
251
+ },
252
+ session: {
253
+ id: 'sess-1',
254
+ userId: 'cookie-user'
255
+ }
256
+ });
257
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
258
+ headers: {
259
+ cookie: 'better-auth.session_token=valid-session'
260
+ }
261
+ });
262
+ const body = await response.json();
263
+ expect(response.status).toBe(200);
264
+ expect(body).toEqual({
265
+ ok: true,
266
+ userId: 'cookie-user'
267
+ });
268
+ expect(authService.getSession).toHaveBeenCalled();
269
+ });
270
+ it('returns 401 when session cookie is invalid and no Bearer token', async ()=>{
271
+ authService.getSession.mockResolvedValue(null);
272
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
273
+ headers: {
274
+ cookie: 'better-auth.session_token=expired'
275
+ }
276
+ });
277
+ const body = await response.json();
278
+ expect(response.status).toBe(401);
279
+ expect(body.message).toBe('Bearer token required');
280
+ });
281
+ });
282
+
283
+ //# sourceMappingURL=mcp-proxy-auth-http.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/__tests__/integration/mcp-proxy-auth-http.test.ts"],"sourcesContent":["import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport type { ConfigService } from '@nestjs/config';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter.js';\nimport { AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport { McpOAuthGuard } from '../../modules/auth/mcp-oauth.guard.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from '../../modules/auth/mcp-oauth.utils.js';\n\ntype MockAuthService = {\n validateMcpToken: ReturnType<typeof vi.fn>;\n getSession: ReturnType<typeof vi.fn>;\n};\n\ntype MockConfigService = {\n get: ReturnType<typeof vi.fn>;\n};\n\nfunction createConfigService(values: Record<string, unknown>): MockConfigService {\n return {\n get: vi.fn((key: string) => values[key]),\n };\n}\n\nfunction createAuthServiceMock(): MockAuthService {\n return {\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getSession: vi.fn().mockResolvedValue(null),\n };\n}\n\nasync function startTestApp(options?: {\n backendUrl?: string;\n}): Promise<{\n close: () => Promise<void>;\n authService: MockAuthService;\n baseUrl: string;\n}> {\n const authService = createAuthServiceMock();\n const configService = createConfigService({\n 'app.port': 3001,\n BETTER_AUTH_URL: options?.backendUrl ?? 'http://localhost:3001',\n });\n const guard = new McpOAuthGuard(authService as unknown as AuthService, configService as never);\n const filter = new AllExceptionsFilter();\n const backendOrigin = resolvePublicBackendOrigin(configService as unknown as ConfigService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService as unknown as ConfigService);\n\n const createRequest = (req: IncomingMessage) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n return {\n method: req.method ?? 'GET',\n url: `${url.pathname}${url.search}`,\n headers: req.headers,\n query: Object.fromEntries(url.searchParams.entries()),\n user: undefined,\n };\n };\n\n const createResponse = (res: ServerResponse) => {\n const response = {\n setHeader(name: string, value: string) {\n res.setHeader(name, value);\n return response;\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n json(body: unknown) {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(body));\n },\n };\n\n return response;\n };\n\n const handleGuardedRoute = async (\n nodeRequest: IncomingMessage,\n nodeResponse: ServerResponse,\n responseBody: (request: ReturnType<typeof createRequest>) => Record<string, unknown>\n ) => {\n const request = createRequest(nodeRequest);\n const response = createResponse(nodeResponse);\n const context = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n const host = {\n switchToHttp: () => ({\n getRequest: () => request,\n getResponse: () => response,\n }),\n } as never;\n\n try {\n await guard.canActivate(context);\n response.json(responseBody(request));\n } catch (error) {\n filter.catch(error, host);\n }\n };\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/test') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/mcp/sse') {\n await handleGuardedRoute(req, res, (request) => ({\n ok: true,\n token: request.query.access_token ?? null,\n userId: (request as { user?: AuthUser }).user?.id ?? null,\n }));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {\n res.setHeader('content-type', 'application/json');\n res.end(JSON.stringify(createMcpProtectedResourceMetadata(configService as unknown as ConfigService)));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n issuer: backendOrigin,\n authorization_endpoint: `${authBaseUrl}/mcp/authorize`,\n token_endpoint: `${authBaseUrl}/mcp/token`,\n registration_endpoint: `${authBaseUrl}/mcp/register`,\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n })\n );\n return;\n }\n\n if (req.method === 'POST' && url.pathname === '/api/auth/mcp/register') {\n res.statusCode = 201;\n res.setHeader('content-type', 'application/json');\n res.end(\n JSON.stringify({\n client_id: 'cursor-client',\n client_secret: 'cursor-secret',\n redirect_uris: ['https://cursor.sh/callback'],\n })\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n return {\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n }),\n authService,\n baseUrl: `http://127.0.0.1:${address.port}`,\n };\n}\n\ndescribe('MCP proxy auth HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let authService: MockAuthService;\n let baseUrl: string;\n\n beforeEach(async () => {\n const result = await startTestApp({\n backendUrl: 'http://localhost:9631',\n });\n close = result.close;\n authService = result.authService;\n baseUrl = result.baseUrl;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('returns 401 with MCP discovery headers when token is missing', async () => {\n const response = await fetch(`${baseUrl}/api/mcp/test`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(response.headers.get('access-control-expose-headers')).toBe('WWW-Authenticate');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata=');\n expect(response.headers.get('www-authenticate')).toContain('resource_metadata_uri=');\n expect(body.message).toBe('Bearer token required');\n });\n\n it('serves protected resource metadata with Better Auth-backed URLs', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n resource: 'http://localhost:9631/api/mcp',\n authorization_servers: ['http://localhost:9631'],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: 'http://localhost:9631/api/auth/mcp/jwks',\n resource_signing_alg_values_supported: ['RS256', 'none'],\n });\n });\n\n it('serves authorization server metadata with MCP registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body.registration_endpoint).toBe('http://localhost:9631/api/auth/mcp/register');\n expect(body.authorization_endpoint).toBe('http://localhost:9631/api/auth/mcp/authorize');\n expect(body.token_endpoint).toBe('http://localhost:9631/api/auth/mcp/token');\n });\n\n it('exposes the advertised registration endpoint', async () => {\n const response = await fetch(`${baseUrl}/api/auth/mcp/register`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n redirect_uris: ['https://cursor.sh/callback'],\n }),\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(201);\n expect(body.client_id).toBe('cursor-client');\n });\n\n it('accepts access_token query params for SSE fallback', async () => {\n authService.validateMcpToken.mockResolvedValue({\n id: 'user-sse',\n name: 'SSE User',\n email: 'sse@example.com',\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/sse?access_token=sse-token`);\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({\n ok: true,\n token: 'sse-token',\n userId: 'user-sse',\n });\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n\n it('accepts session cookie when no Bearer token is present', async () => {\n authService.getSession.mockResolvedValue({\n user: { id: 'cookie-user', name: 'Cookie User', email: 'cookie@example.com' },\n session: { id: 'sess-1', userId: 'cookie-user' },\n });\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=valid-session' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(200);\n expect(body).toEqual({ ok: true, userId: 'cookie-user' });\n expect(authService.getSession).toHaveBeenCalled();\n });\n\n it('returns 401 when session cookie is invalid and no Bearer token', async () => {\n authService.getSession.mockResolvedValue(null);\n\n const response = await fetch(`${baseUrl}/api/mcp/test`, {\n headers: { cookie: 'better-auth.session_token=expired' },\n });\n const body = (await response.json()) as Record<string, unknown>;\n\n expect(response.status).toBe(401);\n expect(body.message).toBe('Bearer token required');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","getSession","startTestApp","options","authService","configService","BETTER_AUTH_URL","backendUrl","guard","filter","backendOrigin","authBaseUrl","createRequest","req","url","URL","headers","host","method","pathname","search","query","Object","fromEntries","searchParams","entries","user","undefined","createResponse","res","response","setHeader","name","value","status","statusCode","json","body","end","JSON","stringify","handleGuardedRoute","nodeRequest","nodeResponse","responseBody","request","context","switchToHttp","getRequest","getResponse","canActivate","error","catch","server","Promise","resolve","listeningServer","ok","userId","id","token","access_token","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","client_id","client_secret","redirect_uris","listen","address","Error","close","reject","baseUrl","port","result","fetch","toBe","toContain","message","toEqual","resource","authorization_servers","bearer_methods_supported","scopes_supported","resource_signing_alg_values_supported","email","toHaveBeenCalledWith","session","cookie","toHaveBeenCalled"],"mappings":"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"}
@@ -0,0 +1,122 @@
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
+ function createOAuthServiceMock() {
5
+ return {
6
+ discoverAndAuthorize: vi.fn(),
7
+ handleCallback: vi.fn()
8
+ };
9
+ }
10
+ describe('OAuth authorize and callback HTTP contract', ()=>{
11
+ let close;
12
+ let oauthService;
13
+ let baseUrl;
14
+ beforeEach(async ()=>{
15
+ oauthService = createOAuthServiceMock();
16
+ const controller = new OAuthController(oauthService);
17
+ const server = await new Promise((resolve)=>{
18
+ const listeningServer = createServer(async (req, res)=>{
19
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
20
+ const request = {
21
+ protocol: 'http',
22
+ query: Object.fromEntries(url.searchParams.entries()),
23
+ get: (headerName)=>{
24
+ if (headerName.toLowerCase() === 'host') {
25
+ return req.headers.host ?? '';
26
+ }
27
+ return undefined;
28
+ }
29
+ };
30
+ const response = {
31
+ redirect (location) {
32
+ res.statusCode = 302;
33
+ res.setHeader('location', location);
34
+ res.end();
35
+ },
36
+ status (statusCode) {
37
+ res.statusCode = statusCode;
38
+ return response;
39
+ },
40
+ send (body) {
41
+ res.setHeader('content-type', 'text/html; charset=utf-8');
42
+ res.end(body);
43
+ }
44
+ };
45
+ if (req.method === 'GET' && url.pathname.startsWith('/api/oauth/authorize/')) {
46
+ const serverId = url.pathname.split('/').pop() ?? '';
47
+ await controller.authorize(serverId, request, response);
48
+ return;
49
+ }
50
+ if (req.method === 'GET' && url.pathname === '/api/oauth/callback') {
51
+ await controller.callback(typeof request.query.code === 'string' ? request.query.code : '', typeof request.query.state === 'string' ? request.query.state : '', request, response);
52
+ return;
53
+ }
54
+ res.statusCode = 404;
55
+ res.end();
56
+ }).listen(0, ()=>resolve(listeningServer));
57
+ });
58
+ const address = server.address();
59
+ if (!address || typeof address === 'string') {
60
+ throw new Error('Failed to resolve test server address');
61
+ }
62
+ close = ()=>new Promise((resolve, reject)=>{
63
+ server.close((error)=>{
64
+ if (error) {
65
+ reject(error);
66
+ return;
67
+ }
68
+ resolve();
69
+ });
70
+ });
71
+ baseUrl = `http://127.0.0.1:${address.port}`;
72
+ });
73
+ afterEach(async ()=>{
74
+ if (close) {
75
+ await close();
76
+ }
77
+ });
78
+ it('redirects authorize requests to the discovered authorization URL', async ()=>{
79
+ oauthService.discoverAndAuthorize.mockResolvedValue('https://auth.example.com/authorize?client_id=abc');
80
+ const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`, {
81
+ redirect: 'manual'
82
+ });
83
+ expect(response.status).toBe(302);
84
+ expect(response.headers.get('location')).toBe('https://auth.example.com/authorize?client_id=abc');
85
+ expect(oauthService.discoverAndAuthorize).toHaveBeenCalledWith('server-1', `${baseUrl}/api/oauth/callback`);
86
+ });
87
+ it('renders an error page when authorize discovery fails', async ()=>{
88
+ oauthService.discoverAndAuthorize.mockRejectedValue(new Error('Metadata discovery failed'));
89
+ const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`);
90
+ const html = await response.text();
91
+ expect(response.status).toBe(400);
92
+ expect(html).toContain('Error: Metadata discovery failed');
93
+ });
94
+ it('rejects callbacks without code or state', async ()=>{
95
+ const response = await fetch(`${baseUrl}/api/oauth/callback`);
96
+ const html = await response.text();
97
+ expect(response.status).toBe(400);
98
+ expect(html).toContain('Missing code or state parameter');
99
+ });
100
+ it('renders a success page for valid callbacks', async ()=>{
101
+ oauthService.handleCallback.mockResolvedValue({
102
+ success: true
103
+ });
104
+ const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
105
+ const html = await response.text();
106
+ expect(response.status).toBe(200);
107
+ expect(html).toContain('Authorization successful! This window will close.');
108
+ expect(oauthService.handleCallback).toHaveBeenCalledWith('server-1', 'auth-code', `${baseUrl}/api/oauth/callback`);
109
+ });
110
+ it('renders a failure page when callback token exchange fails', async ()=>{
111
+ oauthService.handleCallback.mockResolvedValue({
112
+ success: false,
113
+ error: 'Token exchange failed'
114
+ });
115
+ const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);
116
+ const html = await response.text();
117
+ expect(response.status).toBe(200);
118
+ expect(html).toContain('Error: Token exchange failed');
119
+ });
120
+ });
121
+
122
+ //# sourceMappingURL=oauth-authorize-callback.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/__tests__/integration/oauth-authorize-callback.test.ts"],"sourcesContent":["import { createServer } from 'node:http';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { OAuthController } from '../../modules/oauth/oauth.controller.js';\nimport { OAuthService } from '../../modules/oauth/oauth.service.js';\n\nfunction createOAuthServiceMock() {\n return {\n discoverAndAuthorize: vi.fn(),\n handleCallback: vi.fn(),\n };\n}\n\ndescribe('OAuth authorize and callback HTTP contract', () => {\n let close: (() => Promise<void>) | undefined;\n let oauthService: ReturnType<typeof createOAuthServiceMock>;\n let baseUrl: string;\n\n beforeEach(async () => {\n oauthService = createOAuthServiceMock();\n const controller = new OAuthController(oauthService as unknown as OAuthService);\n\n const server = await new Promise<import('node:http').Server>((resolve) => {\n const listeningServer = createServer(async (req, res) => {\n const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);\n const request = {\n protocol: 'http',\n query: Object.fromEntries(url.searchParams.entries()),\n get: (headerName: string) => {\n if (headerName.toLowerCase() === 'host') {\n return req.headers.host ?? '';\n }\n return undefined;\n },\n };\n const response = {\n redirect(location: string) {\n res.statusCode = 302;\n res.setHeader('location', location);\n res.end();\n },\n status(statusCode: number) {\n res.statusCode = statusCode;\n return response;\n },\n send(body: string) {\n res.setHeader('content-type', 'text/html; charset=utf-8');\n res.end(body);\n },\n };\n\n if (req.method === 'GET' && url.pathname.startsWith('/api/oauth/authorize/')) {\n const serverId = url.pathname.split('/').pop() ?? '';\n await controller.authorize(serverId, request as never, response as never);\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/api/oauth/callback') {\n await controller.callback(\n typeof request.query.code === 'string' ? request.query.code : '',\n typeof request.query.state === 'string' ? request.query.state : '',\n request as never,\n response as never\n );\n return;\n }\n\n res.statusCode = 404;\n res.end();\n }).listen(0, () => resolve(listeningServer));\n });\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('Failed to resolve test server address');\n }\n\n close = () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve();\n });\n });\n baseUrl = `http://127.0.0.1:${address.port}`;\n });\n\n afterEach(async () => {\n if (close) {\n await close();\n }\n });\n\n it('redirects authorize requests to the discovered authorization URL', async () => {\n oauthService.discoverAndAuthorize.mockResolvedValue('https://auth.example.com/authorize?client_id=abc');\n\n const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`, {\n redirect: 'manual',\n });\n\n expect(response.status).toBe(302);\n expect(response.headers.get('location')).toBe('https://auth.example.com/authorize?client_id=abc');\n expect(oauthService.discoverAndAuthorize).toHaveBeenCalledWith(\n 'server-1',\n `${baseUrl}/api/oauth/callback`\n );\n });\n\n it('renders an error page when authorize discovery fails', async () => {\n oauthService.discoverAndAuthorize.mockRejectedValue(new Error('Metadata discovery failed'));\n\n const response = await fetch(`${baseUrl}/api/oauth/authorize/server-1`);\n const html = await response.text();\n\n expect(response.status).toBe(400);\n expect(html).toContain('Error: Metadata discovery failed');\n });\n\n it('rejects callbacks without code or state', async () => {\n const response = await fetch(`${baseUrl}/api/oauth/callback`);\n const html = await response.text();\n\n expect(response.status).toBe(400);\n expect(html).toContain('Missing code or state parameter');\n });\n\n it('renders a success page for valid callbacks', async () => {\n oauthService.handleCallback.mockResolvedValue({ success: true });\n\n const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);\n const html = await response.text();\n\n expect(response.status).toBe(200);\n expect(html).toContain('Authorization successful! This window will close.');\n expect(oauthService.handleCallback).toHaveBeenCalledWith(\n 'server-1',\n 'auth-code',\n `${baseUrl}/api/oauth/callback`\n );\n });\n\n it('renders a failure page when callback token exchange fails', async () => {\n oauthService.handleCallback.mockResolvedValue({\n success: false,\n error: 'Token exchange failed',\n });\n\n const response = await fetch(`${baseUrl}/api/oauth/callback?code=auth-code&state=server-1`);\n const html = await response.text();\n\n expect(response.status).toBe(200);\n expect(html).toContain('Error: Token exchange failed');\n });\n});\n"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","OAuthController","createOAuthServiceMock","discoverAndAuthorize","fn","handleCallback","close","oauthService","baseUrl","controller","server","Promise","resolve","listeningServer","req","res","url","URL","headers","host","request","protocol","query","Object","fromEntries","searchParams","entries","get","headerName","toLowerCase","undefined","response","redirect","location","statusCode","setHeader","end","status","send","body","method","pathname","startsWith","serverId","split","pop","authorize","callback","code","state","listen","address","Error","reject","error","port","mockResolvedValue","fetch","toBe","toHaveBeenCalledWith","mockRejectedValue","html","text","toContain","success"],"mappings":"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"}