@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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +27 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +246 -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 +121 -111
  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 +37 -0
  14. package/dist/main.js.map +1 -1
  15. package/dist/modules/auth/auth.config.js +5 -2
  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 +70 -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/mcp/mcp.service.js +48 -8
  26. package/dist/modules/mcp/mcp.service.js.map +1 -1
  27. package/dist/modules/oauth/oauth.controller.js +78 -1
  28. package/dist/modules/oauth/oauth.controller.js.map +1 -1
  29. package/dist/modules/oauth/oauth.service.js +197 -1
  30. package/dist/modules/oauth/oauth.service.js.map +1 -1
  31. package/dist/modules/proxy/proxy.controller.js +152 -27
  32. package/dist/modules/proxy/proxy.controller.js.map +1 -1
  33. package/dist/modules/proxy/proxy.service.js +28 -4
  34. package/dist/modules/proxy/proxy.service.js.map +1 -1
  35. package/docker-entrypoint.sh +15 -2
  36. package/package.json +7 -7
  37. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +281 -0
  38. package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
  39. package/src/__tests__/integration/proxy-auth.test.ts +119 -168
  40. package/src/__tests__/unit/auth.guard.test.ts +12 -2
  41. package/src/common/filters/all-exceptions.filter.ts +11 -0
  42. package/src/main.ts +32 -1
  43. package/src/modules/auth/auth.config.ts +4 -1
  44. package/src/modules/auth/auth.module.ts +3 -2
  45. package/src/modules/auth/auth.service.ts +2 -2
  46. package/src/modules/auth/mcp-oauth.guard.ts +75 -0
  47. package/src/modules/auth/mcp-oauth.utils.ts +80 -0
  48. package/src/modules/mcp/mcp.service.ts +54 -12
  49. package/src/modules/oauth/oauth.controller.ts +84 -1
  50. package/src/modules/oauth/oauth.service.ts +218 -1
  51. package/src/modules/proxy/proxy.controller.ts +120 -25
  52. package/src/modules/proxy/proxy.service.ts +26 -4
  53. package/vitest.config.ts +2 -1
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Integration Tests: Proxy controller auth
3
3
  *
4
- * Verifies that:
5
- * - Proxy routes are @Public() (no session cookie required)
6
- * - Bearer token validation via resolveUser flow
4
+ * Verifies:
5
+ * - McpOAuthGuard always enforces Bearer token
6
+ * - WWW-Authenticate header with resource_metadata_uri on 401
7
+ * - Valid tokens resolve user and pass through
7
8
  * - Org-scoped profile lookup through the proxy service
8
- * - Unauthenticated access uses __unauthenticated__ sentinel
9
- * - Gateway endpoint still works with handleGatewayRequest
9
+ * - Gateway endpoint uses default profile with user scoping
10
10
  */
11
11
 
12
12
  import { NotFoundException, UnauthorizedException } from '@nestjs/common';
@@ -17,6 +17,7 @@ import type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/
17
17
  import type { SettingsService } from '../../modules/settings/settings.service.js';
18
18
 
19
19
  // Dynamic import to handle ESM
20
+ const { McpOAuthGuard } = await import('../../modules/auth/mcp-oauth.guard.js');
20
21
  const { ProxyController } = await import('../../modules/proxy/proxy.controller.js');
21
22
 
22
23
  // ────────────────────────────────────────────────
@@ -33,6 +34,17 @@ function createMockAuthService() {
33
34
  };
34
35
  }
35
36
 
37
+ function createMockConfigService(overrides: Record<string, unknown> = {}) {
38
+ const defaults: Record<string, unknown> = {
39
+ 'app.port': 3001,
40
+ BETTER_AUTH_URL: 'http://localhost:3001',
41
+ };
42
+ const config = { ...defaults, ...overrides };
43
+ return {
44
+ get: vi.fn((key: string) => config[key]),
45
+ };
46
+ }
47
+
36
48
  function createMockProxyService() {
37
49
  return {
38
50
  handleRequest: vi.fn().mockResolvedValue({
@@ -73,204 +85,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
- describe('Proxy controller auth', () => {
84
- let controller: InstanceType<typeof ProxyController>;
97
+ function createMockExecutionContext(req: import('express').Request) {
98
+ return {
99
+ switchToHttp: () => ({
100
+ getRequest: () => req,
101
+ getResponse: () => ({}),
102
+ }),
103
+ getHandler: () => ({}),
104
+ getClass: () => ({}),
105
+ } as any;
106
+ }
107
+
108
+ // ────────────────────────────────────────────────
109
+ // McpOAuthGuard tests
110
+ // ────────────────────────────────────────────────
111
+
112
+ describe('McpOAuthGuard', () => {
113
+ let guard: InstanceType<typeof McpOAuthGuard>;
85
114
  let authService: ReturnType<typeof createMockAuthService>;
86
- let proxyService: ReturnType<typeof createMockProxyService>;
87
- let settingsService: ReturnType<typeof createMockSettingsService>;
88
- let eventEmitter: ReturnType<typeof createMockEventEmitter>;
89
115
 
90
- // ────────────────────────────────────────────────
91
- // Unauthenticated access (no Bearer token)
92
- // ────────────────────────────────────────────────
93
-
94
- describe('unauthenticated access (no Bearer token)', () => {
95
- beforeEach(() => {
96
- authService = createMockAuthService();
97
- proxyService = createMockProxyService();
98
- settingsService = createMockSettingsService();
99
- eventEmitter = createMockEventEmitter();
100
-
101
- controller = new ProxyController(
102
- proxyService as unknown as ProxyService,
103
- settingsService as unknown as SettingsService,
104
- eventEmitter as unknown as EventEmitter2,
105
- authService as unknown as AuthService
106
- );
107
- });
116
+ beforeEach(() => {
117
+ authService = createMockAuthService();
118
+ const configService = createMockConfigService();
119
+ guard = new McpOAuthGuard(
120
+ authService as unknown as AuthService,
121
+ configService as any
122
+ );
123
+ });
108
124
 
109
- it('org-scoped POST request passes __unauthenticated__ to proxy service', async () => {
110
- const req = createMockRequest();
111
- const mcpRequest: McpRequest = {
112
- jsonrpc: '2.0',
113
- id: 1,
114
- method: 'tools/list',
115
- };
125
+ it('rejects request without Bearer token with 401', async () => {
126
+ const req = createMockRequest();
127
+ const ctx = createMockExecutionContext(req);
116
128
 
117
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
129
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
130
+ });
118
131
 
119
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
120
- 'my-profile',
121
- 'my-org',
122
- mcpRequest,
123
- '__unauthenticated__'
124
- );
125
- });
132
+ it('returns WWW-Authenticate header on missing token', async () => {
133
+ const req = createMockRequest();
134
+ const ctx = createMockExecutionContext(req);
135
+
136
+ try {
137
+ await guard.canActivate(ctx);
138
+ } catch (error: any) {
139
+ expect(error.wwwAuthenticate).toContain('resource_metadata=');
140
+ expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');
141
+ expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');
142
+ }
143
+ });
126
144
 
127
- it('gateway POST request passes __unauthenticated__', async () => {
128
- const req = createMockRequest();
129
- const mcpRequest: McpRequest = {
130
- jsonrpc: '2.0',
131
- id: 1,
132
- method: 'initialize',
133
- };
145
+ it('rejects invalid Bearer token', async () => {
146
+ authService.validateMcpToken.mockResolvedValue(null);
147
+ const req = createMockRequest({ authorization: 'Bearer bad-token' });
148
+ const ctx = createMockExecutionContext(req);
134
149
 
135
- await controller.handleGatewayRequest(req, mcpRequest);
150
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
151
+ expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');
152
+ });
136
153
 
137
- expect(proxyService.handleRequest).toHaveBeenCalledWith(
138
- 'default',
139
- mcpRequest,
140
- '__unauthenticated__'
141
- );
142
- });
154
+ it('allows valid Bearer token and attaches user', async () => {
155
+ const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };
156
+ authService.validateMcpToken.mockResolvedValue(user);
157
+ const req = createMockRequest({ authorization: 'Bearer valid-token' });
158
+ const ctx = createMockExecutionContext(req);
143
159
 
144
- it('non-Bearer Authorization header falls back to __unauthenticated__', async () => {
145
- const req = createMockRequest({
146
- authorization: 'Basic dXNlcjpwYXNz',
147
- });
148
- const mcpRequest: McpRequest = {
149
- jsonrpc: '2.0',
150
- id: 1,
151
- method: 'tools/list',
152
- };
160
+ const result = await guard.canActivate(ctx);
153
161
 
154
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
162
+ expect(result).toBe(true);
163
+ expect(req.user).toEqual(user);
164
+ });
155
165
 
156
- expect(authService.validateMcpToken).not.toHaveBeenCalled();
157
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
158
- 'my-profile',
159
- 'my-org',
160
- mcpRequest,
161
- '__unauthenticated__'
162
- );
163
- });
166
+ it('accepts access_token query param (SSE fallback)', async () => {
167
+ const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };
168
+ authService.validateMcpToken.mockResolvedValue(user);
169
+ const req = createMockRequest({}, { access_token: 'sse-token' });
170
+ const ctx = createMockExecutionContext(req);
171
+
172
+ const result = await guard.canActivate(ctx);
173
+
174
+ expect(result).toBe(true);
175
+ expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
164
176
  });
177
+ });
165
178
 
166
- // ────────────────────────────────────────────────
167
- // Authenticated access with Bearer tokens
168
- // ────────────────────────────────────────────────
169
-
170
- describe('authenticated access with Bearer tokens', () => {
171
- const validUser: AuthUser = {
172
- id: 'user-1',
173
- name: 'Token User',
174
- email: 'token@example.com',
175
- };
176
-
177
- beforeEach(() => {
178
- authService = createMockAuthService();
179
- proxyService = createMockProxyService();
180
- settingsService = createMockSettingsService();
181
- eventEmitter = createMockEventEmitter();
182
-
183
- controller = new ProxyController(
184
- proxyService as unknown as ProxyService,
185
- settingsService as unknown as SettingsService,
186
- eventEmitter as unknown as EventEmitter2,
187
- authService as unknown as AuthService
188
- );
189
- });
179
+ // ────────────────────────────────────────────────
180
+ // Proxy controller auth tests
181
+ // ────────────────────────────────────────────────
190
182
 
191
- it('valid Bearer token resolves to authenticated user for org-scoped request', async () => {
192
- authService.validateMcpToken.mockResolvedValue(validUser);
193
- const req = createMockRequest({
194
- authorization: 'Bearer valid-mcp-token',
195
- });
196
- const mcpRequest: McpRequest = {
197
- jsonrpc: '2.0',
198
- id: 1,
199
- method: 'tools/list',
200
- };
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
- await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);
189
+ beforeEach(() => {
190
+ proxyService = createMockProxyService();
191
+ settingsService = createMockSettingsService();
192
+ eventEmitter = createMockEventEmitter();
203
193
 
204
- expect(authService.validateMcpToken).toHaveBeenCalledWith('valid-mcp-token');
205
- expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(
206
- 'my-profile',
207
- 'my-org',
208
- mcpRequest,
209
- 'user-1'
210
- );
211
- });
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
- it('invalid Bearer token throws UnauthorizedException', async () => {
214
- authService.validateMcpToken.mockResolvedValue(null);
215
- const req = createMockRequest({
216
- authorization: 'Bearer invalid-token',
217
- });
218
- const mcpRequest: McpRequest = {
219
- jsonrpc: '2.0',
220
- id: 1,
221
- method: 'tools/list',
222
- };
223
-
224
- await expect(
225
- controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest)
226
- ).rejects.toThrow(UnauthorizedException);
227
- });
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
- it('no Authorization header falls back to __unauthenticated__', async () => {
230
- const req = createMockRequest();
231
- const mcpRequest: McpRequest = {
232
- jsonrpc: '2.0',
233
- id: 1,
234
- method: 'tools/list',
235
- };
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
- '__unauthenticated__'
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: null };
55
+ const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: 'org-1' };
56
56
  authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });
57
57
 
58
58
  const ctx = createMockExecutionContext({ cookie: 'session=abc' });
@@ -63,4 +63,14 @@ describe('AuthGuard', () => {
63
63
  expect(request.user).toEqual(mockUser);
64
64
  expect(request.sessionData).toEqual(mockSession);
65
65
  });
66
+
67
+ it('should throw ForbiddenException when session has no active organization', async () => {
68
+ const mockUser = { id: 'user-1', name: 'Test', email: 'test@example.com', image: null };
69
+ const mockSession = { id: 'sess-1', userId: 'user-1', activeOrganizationId: null };
70
+ authService.getSession.mockResolvedValue({ user: mockUser, session: mockSession });
71
+
72
+ const ctx = createMockExecutionContext({ cookie: 'session=abc' });
73
+
74
+ await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
75
+ });
66
76
  });
@@ -23,6 +23,10 @@ interface ErrorResponse {
23
23
  requestId?: string;
24
24
  }
25
25
 
26
+ type McpHttpException = HttpException & {
27
+ wwwAuthenticate?: string;
28
+ };
29
+
26
30
  @Catch()
27
31
  export class AllExceptionsFilter implements ExceptionFilter {
28
32
  private readonly logger = new Logger(AllExceptionsFilter.name);
@@ -72,6 +76,13 @@ export class AllExceptionsFilter implements ExceptionFilter {
72
76
  errorResponse.requestId = requestId;
73
77
  }
74
78
 
79
+ // Set WWW-Authenticate header for MCP OAuth errors (RFC 9728)
80
+ const mcpException = exception instanceof HttpException ? (exception as McpHttpException) : null;
81
+ if (mcpException?.wwwAuthenticate) {
82
+ response.setHeader('WWW-Authenticate', mcpException.wwwAuthenticate);
83
+ response.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate');
84
+ }
85
+
75
86
  response.status(status).json(errorResponse);
76
87
  }
77
88
  }
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: any, res: any, next: any) => {
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: '/sign-in',
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: { token: bearerToken },
104
+ where: { accessToken: bearerToken },
105
105
  });
106
106
 
107
107
  if (!tokenRecord || !tokenRecord.userId) return null;
108
108
 
109
109
  // Check expiration
110
- if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
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
+ }