@dxheroes/local-mcp-backend 0.10.0 → 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.
@@ -1,9 +1,9 @@
1
1
 
2
- > @dxheroes/local-mcp-backend@0.10.0 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: 60 files with swc (100.08ms)
9
+ Successfully compiled: 60 files with swc (99.01ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
3
28
  ## [0.10.0](https://github.com/DXHeroes/local-mcp-gateway/compare/backend-v0.9.2...backend-v0.10.0) (2026-03-12)
4
29
 
5
30
 
@@ -10,7 +10,8 @@ function createConfigService(values) {
10
10
  }
11
11
  function createAuthServiceMock() {
12
12
  return {
13
- validateMcpToken: vi.fn().mockResolvedValue(null)
13
+ validateMcpToken: vi.fn().mockResolvedValue(null),
14
+ getSession: vi.fn().mockResolvedValue(null)
14
15
  };
15
16
  }
16
17
  async function startTestApp(options) {
@@ -241,6 +242,42 @@ describe('MCP proxy auth HTTP contract', ()=>{
241
242
  });
242
243
  expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
243
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
+ });
244
281
  });
245
282
 
246
283
  //# sourceMappingURL=mcp-proxy-auth-http.test.js.map
@@ -1 +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};\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 };\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"],"names":["createServer","afterEach","beforeEach","describe","expect","it","vi","AllExceptionsFilter","McpOAuthGuard","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","createConfigService","values","get","fn","key","createAuthServiceMock","validateMcpToken","mockResolvedValue","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"],"mappings":"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"}
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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"}
@@ -170,6 +170,57 @@ describe('McpOAuthGuard', ()=>{
170
170
  expect(result).toBe(true);
171
171
  expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
172
172
  });
173
+ it('accepts session cookie when no Bearer token is present', async ()=>{
174
+ const user = {
175
+ id: 'session-user',
176
+ name: 'Session',
177
+ email: 'session@example.com'
178
+ };
179
+ authService.getSession.mockResolvedValue({
180
+ user,
181
+ session: {
182
+ id: 's1',
183
+ userId: user.id
184
+ }
185
+ });
186
+ const req = createMockRequest({
187
+ cookie: 'better-auth.session_token=abc123'
188
+ });
189
+ const ctx = createMockExecutionContext(req);
190
+ const result = await guard.canActivate(ctx);
191
+ expect(result).toBe(true);
192
+ expect(req.user).toEqual(user);
193
+ expect(authService.validateMcpToken).not.toHaveBeenCalled();
194
+ });
195
+ it('rejects when neither Bearer token nor session cookie is valid', async ()=>{
196
+ authService.getSession.mockResolvedValue(null);
197
+ const req = createMockRequest({
198
+ cookie: 'better-auth.session_token=expired'
199
+ });
200
+ const ctx = createMockExecutionContext(req);
201
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
202
+ });
203
+ it('does not fall through to session cookie when Bearer token is invalid', async ()=>{
204
+ const user = {
205
+ id: 'session-user',
206
+ name: 'Session',
207
+ email: 'session@example.com'
208
+ };
209
+ authService.validateMcpToken.mockResolvedValue(null);
210
+ authService.getSession.mockResolvedValue({
211
+ user,
212
+ session: {
213
+ id: 's1',
214
+ userId: user.id
215
+ }
216
+ });
217
+ const req = createMockRequest({
218
+ authorization: 'Bearer bad-token'
219
+ });
220
+ const ctx = createMockExecutionContext(req);
221
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
222
+ expect(authService.getSession).not.toHaveBeenCalled();
223
+ });
173
224
  });
174
225
  // ────────────────────────────────────────────────
175
226
  // Proxy controller auth tests
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/__tests__/integration/proxy-auth.test.ts"],"sourcesContent":["/**\n * Integration Tests: Proxy controller auth\n *\n * Verifies:\n * - McpOAuthGuard always enforces Bearer token\n * - WWW-Authenticate header with resource_metadata_uri on 401\n * - Valid tokens resolve user and pass through\n * - Org-scoped profile lookup through the proxy service\n * - Gateway endpoint uses default profile with user scoping\n */\n\nimport { NotFoundException, UnauthorizedException } from '@nestjs/common';\nimport type { EventEmitter2 } from '@nestjs/event-emitter';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { type AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/proxy.service.js';\nimport type { SettingsService } from '../../modules/settings/settings.service.js';\n\n// Dynamic import to handle ESM\nconst { McpOAuthGuard } = await import('../../modules/auth/mcp-oauth.guard.js');\nconst { ProxyController } = await import('../../modules/proxy/proxy.controller.js');\n\n// ────────────────────────────────────────────────\n// Mock factories\n// ────────────────────────────────────────────────\n\nfunction createMockAuthService() {\n return {\n getAuth: vi.fn(),\n getSession: vi.fn().mockResolvedValue(null),\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getUserOrganizations: vi.fn().mockResolvedValue([]),\n onModuleInit: vi.fn(),\n };\n}\n\nfunction createMockConfigService(overrides: Record<string, unknown> = {}) {\n const defaults: Record<string, unknown> = {\n 'app.port': 3001,\n BETTER_AUTH_URL: 'http://localhost:3001',\n };\n const config = { ...defaults, ...overrides };\n return {\n get: vi.fn((key: string) => config[key]),\n };\n}\n\nfunction createMockProxyService() {\n return {\n handleRequest: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n handleRequestByOrgSlug: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n getProfileInfo: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getProfileInfoByOrgSlug: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getToolsForServer: vi.fn().mockResolvedValue([]),\n };\n}\n\nfunction createMockSettingsService() {\n return {\n getDefaultGatewayProfile: vi.fn().mockResolvedValue('default'),\n getSetting: vi.fn(),\n setSetting: vi.fn(),\n };\n}\n\nfunction createMockEventEmitter() {\n return {\n emit: vi.fn(),\n on: vi.fn(),\n off: vi.fn(),\n };\n}\n\nfunction createMockRequest(headers: Record<string, string> = {}, query: Record<string, string> = {}) {\n return {\n headers,\n query,\n on: vi.fn(),\n user: undefined as any,\n } as unknown as import('express').Request;\n}\n\nfunction createMockExecutionContext(req: import('express').Request) {\n return {\n switchToHttp: () => ({\n getRequest: () => req,\n getResponse: () => ({}),\n }),\n getHandler: () => ({}),\n getClass: () => ({}),\n } as any;\n}\n\n// ────────────────────────────────────────────────\n// McpOAuthGuard tests\n// ────────────────────────────────────────────────\n\ndescribe('McpOAuthGuard', () => {\n let guard: InstanceType<typeof McpOAuthGuard>;\n let authService: ReturnType<typeof createMockAuthService>;\n\n beforeEach(() => {\n authService = createMockAuthService();\n const configService = createMockConfigService();\n guard = new McpOAuthGuard(\n authService as unknown as AuthService,\n configService as any\n );\n });\n\n it('rejects request without Bearer token with 401', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('returns WWW-Authenticate header on missing token', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n try {\n await guard.canActivate(ctx);\n } catch (error: any) {\n expect(error.wwwAuthenticate).toContain('resource_metadata=');\n expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');\n expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');\n }\n });\n\n it('rejects invalid Bearer token', async () => {\n authService.validateMcpToken.mockResolvedValue(null);\n const req = createMockRequest({ authorization: 'Bearer bad-token' });\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');\n });\n\n it('allows valid Bearer token and attaches user', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({ authorization: 'Bearer valid-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(req.user).toEqual(user);\n });\n\n it('accepts access_token query param (SSE fallback)', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({}, { access_token: 'sse-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n});\n\n// ────────────────────────────────────────────────\n// Proxy controller auth tests\n// ────────────────────────────────────────────────\n\ndescribe('Proxy controller auth', () => {\n let controller: InstanceType<typeof ProxyController>;\n let proxyService: ReturnType<typeof createMockProxyService>;\n let settingsService: ReturnType<typeof createMockSettingsService>;\n let eventEmitter: ReturnType<typeof createMockEventEmitter>;\n\n beforeEach(() => {\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2\n );\n });\n\n describe('guard-authenticated user passthrough', () => {\n it('uses req.user set by McpOAuthGuard', async () => {\n const guardUser: AuthUser = { id: 'guard-user', name: 'Guard', email: 'g@test.com' };\n const req = createMockRequest({ authorization: 'Bearer some-token' });\n (req as any).user = guardUser;\n\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n 'guard-user'\n );\n });\n });\n\n describe('org-scoped profile lookup', () => {\n const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };\n const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };\n\n it('org slug and user are passed through to proxy service', async () => {\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'user-a-profile',\n 'org-a',\n mcpRequest,\n 'user-a'\n );\n });\n\n it('different users get different userId passed to proxy', async () => {\n const reqA = createMockRequest({ authorization: 'Bearer token-a' });\n (reqA as any).user = userA;\n const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);\n\n const reqB = createMockRequest({ authorization: 'Bearer token-b' });\n (reqB as any).user = userB;\n\n await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 1,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-a'\n );\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 2,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-b'\n );\n });\n\n it('gateway endpoint uses default profile with user scoping', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleGatewayRequest(req, mcpRequest);\n\n expect(proxyService.handleRequest).toHaveBeenCalledWith('my-default', mcpRequest, 'user-a');\n });\n\n it('gateway wraps NotFoundException with descriptive message', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');\n proxyService.handleRequest.mockRejectedValue(\n new NotFoundException('Profile \"missing-profile\" not found')\n );\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await expect(controller.handleGatewayRequest(req, mcpRequest)).rejects.toThrow(\n NotFoundException\n );\n });\n\n it('org-scoped profile info endpoint uses orgSlug', async () => {\n await controller.getOrgProfileInfo('my-org', 'my-profile');\n\n expect(proxyService.getProfileInfoByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org');\n });\n });\n});\n"],"names":["NotFoundException","UnauthorizedException","beforeEach","describe","expect","it","vi","McpOAuthGuard","ProxyController","createMockAuthService","getAuth","fn","getSession","mockResolvedValue","validateMcpToken","getUserOrganizations","onModuleInit","createMockConfigService","overrides","defaults","BETTER_AUTH_URL","config","get","key","createMockProxyService","handleRequest","jsonrpc","id","result","tools","handleRequestByOrgSlug","getProfileInfo","serverStatus","total","connected","servers","getProfileInfoByOrgSlug","getToolsForServer","createMockSettingsService","getDefaultGatewayProfile","getSetting","setSetting","createMockEventEmitter","emit","on","off","createMockRequest","headers","query","user","undefined","createMockExecutionContext","req","switchToHttp","getRequest","getResponse","getHandler","getClass","guard","authService","configService","ctx","canActivate","rejects","toThrow","error","wwwAuthenticate","toContain","authorization","toHaveBeenCalledWith","name","email","toBe","toEqual","access_token","controller","proxyService","settingsService","eventEmitter","guardUser","mcpRequest","method","handleOrgMcpRequest","userA","userB","reqA","mcpReq","reqB","toHaveBeenNthCalledWith","handleGatewayRequest","mockRejectedValue","getOrgProfileInfo"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/__tests__/integration/proxy-auth.test.ts"],"sourcesContent":["/**\n * Integration Tests: Proxy controller auth\n *\n * Verifies:\n * - McpOAuthGuard always enforces Bearer token\n * - WWW-Authenticate header with resource_metadata_uri on 401\n * - Valid tokens resolve user and pass through\n * - Org-scoped profile lookup through the proxy service\n * - Gateway endpoint uses default profile with user scoping\n */\n\nimport { NotFoundException, UnauthorizedException } from '@nestjs/common';\nimport type { EventEmitter2 } from '@nestjs/event-emitter';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { type AuthService, type AuthUser } from '../../modules/auth/auth.service.js';\nimport type { McpRequest, McpResponse, ProxyService } from '../../modules/proxy/proxy.service.js';\nimport type { SettingsService } from '../../modules/settings/settings.service.js';\n\n// Dynamic import to handle ESM\nconst { McpOAuthGuard } = await import('../../modules/auth/mcp-oauth.guard.js');\nconst { ProxyController } = await import('../../modules/proxy/proxy.controller.js');\n\n// ────────────────────────────────────────────────\n// Mock factories\n// ────────────────────────────────────────────────\n\nfunction createMockAuthService() {\n return {\n getAuth: vi.fn(),\n getSession: vi.fn().mockResolvedValue(null),\n validateMcpToken: vi.fn().mockResolvedValue(null),\n getUserOrganizations: vi.fn().mockResolvedValue([]),\n onModuleInit: vi.fn(),\n };\n}\n\nfunction createMockConfigService(overrides: Record<string, unknown> = {}) {\n const defaults: Record<string, unknown> = {\n 'app.port': 3001,\n BETTER_AUTH_URL: 'http://localhost:3001',\n };\n const config = { ...defaults, ...overrides };\n return {\n get: vi.fn((key: string) => config[key]),\n };\n}\n\nfunction createMockProxyService() {\n return {\n handleRequest: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n handleRequestByOrgSlug: vi.fn().mockResolvedValue({\n jsonrpc: '2.0',\n id: 1,\n result: { tools: [] },\n } as McpResponse),\n getProfileInfo: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getProfileInfoByOrgSlug: vi.fn().mockResolvedValue({\n tools: [],\n serverStatus: { total: 0, connected: 0, servers: {} },\n }),\n getToolsForServer: vi.fn().mockResolvedValue([]),\n };\n}\n\nfunction createMockSettingsService() {\n return {\n getDefaultGatewayProfile: vi.fn().mockResolvedValue('default'),\n getSetting: vi.fn(),\n setSetting: vi.fn(),\n };\n}\n\nfunction createMockEventEmitter() {\n return {\n emit: vi.fn(),\n on: vi.fn(),\n off: vi.fn(),\n };\n}\n\nfunction createMockRequest(headers: Record<string, string> = {}, query: Record<string, string> = {}) {\n return {\n headers,\n query,\n on: vi.fn(),\n user: undefined as any,\n } as unknown as import('express').Request;\n}\n\nfunction createMockExecutionContext(req: import('express').Request) {\n return {\n switchToHttp: () => ({\n getRequest: () => req,\n getResponse: () => ({}),\n }),\n getHandler: () => ({}),\n getClass: () => ({}),\n } as any;\n}\n\n// ────────────────────────────────────────────────\n// McpOAuthGuard tests\n// ────────────────────────────────────────────────\n\ndescribe('McpOAuthGuard', () => {\n let guard: InstanceType<typeof McpOAuthGuard>;\n let authService: ReturnType<typeof createMockAuthService>;\n\n beforeEach(() => {\n authService = createMockAuthService();\n const configService = createMockConfigService();\n guard = new McpOAuthGuard(\n authService as unknown as AuthService,\n configService as any\n );\n });\n\n it('rejects request without Bearer token with 401', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('returns WWW-Authenticate header on missing token', async () => {\n const req = createMockRequest();\n const ctx = createMockExecutionContext(req);\n\n try {\n await guard.canActivate(ctx);\n } catch (error: any) {\n expect(error.wwwAuthenticate).toContain('resource_metadata=');\n expect(error.wwwAuthenticate).toContain('resource_metadata_uri=');\n expect(error.wwwAuthenticate).toContain('/.well-known/oauth-protected-resource');\n }\n });\n\n it('rejects invalid Bearer token', async () => {\n authService.validateMcpToken.mockResolvedValue(null);\n const req = createMockRequest({ authorization: 'Bearer bad-token' });\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('bad-token');\n });\n\n it('allows valid Bearer token and attaches user', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({ authorization: 'Bearer valid-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(req.user).toEqual(user);\n });\n\n it('accepts access_token query param (SSE fallback)', async () => {\n const user: AuthUser = { id: 'user-1', name: 'Test', email: 'test@example.com' };\n authService.validateMcpToken.mockResolvedValue(user);\n const req = createMockRequest({}, { access_token: 'sse-token' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');\n });\n\n it('accepts session cookie when no Bearer token is present', async () => {\n const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };\n authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });\n const req = createMockRequest({ cookie: 'better-auth.session_token=abc123' });\n const ctx = createMockExecutionContext(req);\n\n const result = await guard.canActivate(ctx);\n\n expect(result).toBe(true);\n expect(req.user).toEqual(user);\n expect(authService.validateMcpToken).not.toHaveBeenCalled();\n });\n\n it('rejects when neither Bearer token nor session cookie is valid', async () => {\n authService.getSession.mockResolvedValue(null);\n const req = createMockRequest({ cookie: 'better-auth.session_token=expired' });\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n });\n\n it('does not fall through to session cookie when Bearer token is invalid', async () => {\n const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };\n authService.validateMcpToken.mockResolvedValue(null);\n authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });\n const req = createMockRequest({ authorization: 'Bearer bad-token' });\n const ctx = createMockExecutionContext(req);\n\n await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);\n expect(authService.getSession).not.toHaveBeenCalled();\n });\n});\n\n// ────────────────────────────────────────────────\n// Proxy controller auth tests\n// ────────────────────────────────────────────────\n\ndescribe('Proxy controller auth', () => {\n let controller: InstanceType<typeof ProxyController>;\n let proxyService: ReturnType<typeof createMockProxyService>;\n let settingsService: ReturnType<typeof createMockSettingsService>;\n let eventEmitter: ReturnType<typeof createMockEventEmitter>;\n\n beforeEach(() => {\n proxyService = createMockProxyService();\n settingsService = createMockSettingsService();\n eventEmitter = createMockEventEmitter();\n\n controller = new ProxyController(\n proxyService as unknown as ProxyService,\n settingsService as unknown as SettingsService,\n eventEmitter as unknown as EventEmitter2\n );\n });\n\n describe('guard-authenticated user passthrough', () => {\n it('uses req.user set by McpOAuthGuard', async () => {\n const guardUser: AuthUser = { id: 'guard-user', name: 'Guard', email: 'g@test.com' };\n const req = createMockRequest({ authorization: 'Bearer some-token' });\n (req as any).user = guardUser;\n\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'my-org', 'my-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'my-profile',\n 'my-org',\n mcpRequest,\n 'guard-user'\n );\n });\n });\n\n describe('org-scoped profile lookup', () => {\n const userA: AuthUser = { id: 'user-a', name: 'User A', email: 'a@test.com' };\n const userB: AuthUser = { id: 'user-b', name: 'User B', email: 'b@test.com' };\n\n it('org slug and user are passed through to proxy service', async () => {\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(req, 'org-a', 'user-a-profile', mcpRequest);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenCalledWith(\n 'user-a-profile',\n 'org-a',\n mcpRequest,\n 'user-a'\n );\n });\n\n it('different users get different userId passed to proxy', async () => {\n const reqA = createMockRequest({ authorization: 'Bearer token-a' });\n (reqA as any).user = userA;\n const mcpReq: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleOrgMcpRequest(reqA, 'shared-org', 'shared-profile', mcpReq);\n\n const reqB = createMockRequest({ authorization: 'Bearer token-b' });\n (reqB as any).user = userB;\n\n await controller.handleOrgMcpRequest(reqB, 'shared-org', 'shared-profile', mcpReq);\n\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 1,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-a'\n );\n expect(proxyService.handleRequestByOrgSlug).toHaveBeenNthCalledWith(\n 2,\n 'shared-profile',\n 'shared-org',\n mcpReq,\n 'user-b'\n );\n });\n\n it('gateway endpoint uses default profile with user scoping', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('my-default');\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await controller.handleGatewayRequest(req, mcpRequest);\n\n expect(proxyService.handleRequest).toHaveBeenCalledWith('my-default', mcpRequest, 'user-a');\n });\n\n it('gateway wraps NotFoundException with descriptive message', async () => {\n settingsService.getDefaultGatewayProfile.mockResolvedValue('missing-profile');\n proxyService.handleRequest.mockRejectedValue(\n new NotFoundException('Profile \"missing-profile\" not found')\n );\n\n const req = createMockRequest({ authorization: 'Bearer token-a' });\n (req as any).user = userA;\n const mcpRequest: McpRequest = { jsonrpc: '2.0', id: 1, method: 'tools/list' };\n\n await expect(controller.handleGatewayRequest(req, mcpRequest)).rejects.toThrow(\n NotFoundException\n );\n });\n\n it('org-scoped profile info endpoint uses orgSlug', async () => {\n await controller.getOrgProfileInfo('my-org', 'my-profile');\n\n expect(proxyService.getProfileInfoByOrgSlug).toHaveBeenCalledWith('my-profile', 'my-org');\n });\n });\n});\n"],"names":["NotFoundException","UnauthorizedException","beforeEach","describe","expect","it","vi","McpOAuthGuard","ProxyController","createMockAuthService","getAuth","fn","getSession","mockResolvedValue","validateMcpToken","getUserOrganizations","onModuleInit","createMockConfigService","overrides","defaults","BETTER_AUTH_URL","config","get","key","createMockProxyService","handleRequest","jsonrpc","id","result","tools","handleRequestByOrgSlug","getProfileInfo","serverStatus","total","connected","servers","getProfileInfoByOrgSlug","getToolsForServer","createMockSettingsService","getDefaultGatewayProfile","getSetting","setSetting","createMockEventEmitter","emit","on","off","createMockRequest","headers","query","user","undefined","createMockExecutionContext","req","switchToHttp","getRequest","getResponse","getHandler","getClass","guard","authService","configService","ctx","canActivate","rejects","toThrow","error","wwwAuthenticate","toContain","authorization","toHaveBeenCalledWith","name","email","toBe","toEqual","access_token","session","userId","cookie","not","toHaveBeenCalled","controller","proxyService","settingsService","eventEmitter","guardUser","mcpRequest","method","handleOrgMcpRequest","userA","userB","reqA","mcpReq","reqB","toHaveBeenNthCalledWith","handleGatewayRequest","mockRejectedValue","getOrgProfileInfo"],"mappings":"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"}
package/dist/main.js CHANGED
@@ -43,6 +43,10 @@ async function bootstrap() {
43
43
  const configService = app.get(ConfigService);
44
44
  // Security
45
45
  app.use(helmet());
46
+ app.use((_req, res, next)=>{
47
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
48
+ next();
49
+ });
46
50
  app.use(compression());
47
51
  // CORS
48
52
  const corsOrigins = configService.get('CORS_ORIGINS')?.split(',') || [
@@ -81,10 +85,30 @@ async function bootstrap() {
81
85
  // AuthService.onModuleInit() hasn't run yet — auth initializes during app.init().
82
86
  const authService = app.get(AuthService);
83
87
  const expressApp = app.getHttpAdapter().getInstance();
84
- const lazyAuthHandler = (req, res, next)=>{
88
+ const lazyAuthHandler = async (req, res, next)=>{
85
89
  const auth = authService.getAuth();
86
90
  if (!auth) return next();
87
- toNodeHandler(auth)(req, res);
91
+ try {
92
+ await toNodeHandler(auth)(req, res);
93
+ } catch (error) {
94
+ const isPrismaNotFound = error instanceof Error && 'code' in error && error.code === 'P2025';
95
+ if (isPrismaNotFound) {
96
+ // Stale session cookie — clear it and return 401
97
+ res.clearCookie('better-auth.session_token');
98
+ res.clearCookie('better-auth.session_token.sig');
99
+ if (!res.headersSent) {
100
+ res.status(401).json({
101
+ error: 'Session expired'
102
+ });
103
+ }
104
+ return;
105
+ }
106
+ if (!res.headersSent) {
107
+ res.status(500).json({
108
+ error: 'Internal server error'
109
+ });
110
+ }
111
+ }
88
112
  };
89
113
  expressApp.get('/.well-known/oauth-protected-resource', (_req, res)=>{
90
114
  res.json(createMcpProtectedResourceMetadata(configService));
package/dist/main.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/main.ts"],"sourcesContent":["/**\n * NestJS Application Bootstrap\n *\n * Entry point for the Local MCP Gateway backend.\n * Better Auth handles /api/auth/* and /.well-known/* routes.\n */\n\nimport { Logger, ValidationPipe } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { NestFactory } from '@nestjs/core';\nimport { toNodeHandler } from 'better-auth/node';\nimport compression from 'compression';\nimport type { NextFunction, Request, Response } from 'express';\nimport helmet from 'helmet';\nimport { AppModule } from './app.module.js';\nimport { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';\nimport { LoggingInterceptor } from './common/interceptors/logging.interceptor.js';\nimport { AuthService } from './modules/auth/auth.service.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from './modules/auth/mcp-oauth.utils.js';\n\nasync function bootstrap() {\n // Determine log levels from environment\n const logLevel = process.env.LOG_LEVEL || 'log';\n const logLevels: ('error' | 'warn' | 'log' | 'debug' | 'verbose')[] = ['error'];\n if (['warn', 'log', 'debug', 'verbose'].includes(logLevel)) logLevels.push('warn');\n if (['log', 'debug', 'verbose'].includes(logLevel)) logLevels.push('log');\n if (['debug', 'verbose'].includes(logLevel)) logLevels.push('debug');\n if (logLevel === 'verbose') logLevels.push('verbose');\n\n const logger = new Logger('Bootstrap');\n const app = await NestFactory.create(AppModule, {\n logger: logLevels,\n });\n\n const configService = app.get(ConfigService);\n\n // Security\n app.use(helmet());\n app.use(compression());\n\n // CORS\n const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',') || [\n 'http://localhost:5173',\n 'http://localhost:3000',\n ];\n app.enableCors({\n origin: corsOrigins,\n credentials: true,\n methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],\n });\n\n // Global prefix\n app.setGlobalPrefix('api');\n\n // Global pipes\n app.useGlobalPipes(\n new ValidationPipe({\n whitelist: true,\n forbidNonWhitelisted: true,\n transform: true,\n transformOptions: { enableImplicitConversion: true },\n })\n );\n\n // Global filters\n app.useGlobalFilters(new AllExceptionsFilter());\n\n // Global interceptors\n app.useGlobalInterceptors(new LoggingInterceptor());\n\n // Mount Better Auth handler on Express BEFORE app.init() so it registers\n // ahead of NestJS's catch-all 404 handler. We use a lazy wrapper because\n // AuthService.onModuleInit() hasn't run yet — auth initializes during app.init().\n const authService = app.get(AuthService);\n const expressApp = app.getHttpAdapter().getInstance();\n\n const lazyAuthHandler = (req: Request, res: Response, next: NextFunction) => {\n const auth = authService.getAuth();\n if (!auth) return next();\n toNodeHandler(auth)(req, res);\n };\n\n expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {\n res.json(createMcpProtectedResourceMetadata(configService));\n });\n\n // RFC 8414 – OAuth 2.0 Authorization Server Metadata\n // MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)\n // instead of falling back to the root /register which returns 404.\n expressApp.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {\n const backendOrigin = resolvePublicBackendOrigin(configService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService);\n\n res.json({\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 token_endpoint_auth_methods_supported: ['none'],\n code_challenge_methods_supported: ['S256'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n });\n });\n\n expressApp.all('/api/auth/*splat', lazyAuthHandler);\n expressApp.all('/.well-known/*splat', lazyAuthHandler);\n\n logger.log('Better Auth routes registered (lazy) on /api/auth/* and /.well-known/*');\n\n const port = configService.get<number>('PORT') || 3001;\n await app.listen(port);\n\n logger.log(`Application is running on: http://localhost:${port}`);\n logger.log(`API available at: http://localhost:${port}/api`);\n logger.log('Auth: always enabled (email+password baseline)');\n}\n\nbootstrap();\n"],"names":["Logger","ValidationPipe","ConfigService","NestFactory","toNodeHandler","compression","helmet","AppModule","AllExceptionsFilter","LoggingInterceptor","AuthService","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","bootstrap","logLevel","process","env","LOG_LEVEL","logLevels","includes","push","logger","app","create","configService","get","use","corsOrigins","split","enableCors","origin","credentials","methods","setGlobalPrefix","useGlobalPipes","whitelist","forbidNonWhitelisted","transform","transformOptions","enableImplicitConversion","useGlobalFilters","useGlobalInterceptors","authService","expressApp","getHttpAdapter","getInstance","lazyAuthHandler","req","res","next","auth","getAuth","_req","json","backendOrigin","authBaseUrl","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","token_endpoint_auth_methods_supported","code_challenge_methods_supported","scopes_supported","all","log","port","listen"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/main.ts"],"sourcesContent":["/**\n * NestJS Application Bootstrap\n *\n * Entry point for the Local MCP Gateway backend.\n * Better Auth handles /api/auth/* and /.well-known/* routes.\n */\n\nimport { Logger, ValidationPipe } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { NestFactory } from '@nestjs/core';\nimport { toNodeHandler } from 'better-auth/node';\nimport compression from 'compression';\nimport type { NextFunction, Request, Response } from 'express';\nimport helmet from 'helmet';\nimport { AppModule } from './app.module.js';\nimport { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';\nimport { LoggingInterceptor } from './common/interceptors/logging.interceptor.js';\nimport { AuthService } from './modules/auth/auth.service.js';\nimport {\n createMcpProtectedResourceMetadata,\n resolvePublicAuthBaseUrl,\n resolvePublicBackendOrigin,\n} from './modules/auth/mcp-oauth.utils.js';\n\nasync function bootstrap() {\n // Determine log levels from environment\n const logLevel = process.env.LOG_LEVEL || 'log';\n const logLevels: ('error' | 'warn' | 'log' | 'debug' | 'verbose')[] = ['error'];\n if (['warn', 'log', 'debug', 'verbose'].includes(logLevel)) logLevels.push('warn');\n if (['log', 'debug', 'verbose'].includes(logLevel)) logLevels.push('log');\n if (['debug', 'verbose'].includes(logLevel)) logLevels.push('debug');\n if (logLevel === 'verbose') logLevels.push('verbose');\n\n const logger = new Logger('Bootstrap');\n const app = await NestFactory.create(AppModule, {\n logger: logLevels,\n });\n\n const configService = app.get(ConfigService);\n\n // Security\n app.use(helmet());\n app.use((_req: Request, res: Response, next: NextFunction) => {\n res.setHeader('X-Robots-Tag', 'noindex, nofollow');\n next();\n });\n app.use(compression());\n\n // CORS\n const corsOrigins = configService.get<string>('CORS_ORIGINS')?.split(',') || [\n 'http://localhost:5173',\n 'http://localhost:3000',\n ];\n app.enableCors({\n origin: corsOrigins,\n credentials: true,\n methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],\n });\n\n // Global prefix\n app.setGlobalPrefix('api');\n\n // Global pipes\n app.useGlobalPipes(\n new ValidationPipe({\n whitelist: true,\n forbidNonWhitelisted: true,\n transform: true,\n transformOptions: { enableImplicitConversion: true },\n })\n );\n\n // Global filters\n app.useGlobalFilters(new AllExceptionsFilter());\n\n // Global interceptors\n app.useGlobalInterceptors(new LoggingInterceptor());\n\n // Mount Better Auth handler on Express BEFORE app.init() so it registers\n // ahead of NestJS's catch-all 404 handler. We use a lazy wrapper because\n // AuthService.onModuleInit() hasn't run yet — auth initializes during app.init().\n const authService = app.get(AuthService);\n const expressApp = app.getHttpAdapter().getInstance();\n\n const lazyAuthHandler = async (req: Request, res: Response, next: NextFunction) => {\n const auth = authService.getAuth();\n if (!auth) return next();\n try {\n await toNodeHandler(auth)(req, res);\n } catch (error: unknown) {\n const isPrismaNotFound =\n error instanceof Error &&\n 'code' in error &&\n (error as { code: string }).code === 'P2025';\n if (isPrismaNotFound) {\n // Stale session cookie — clear it and return 401\n res.clearCookie('better-auth.session_token');\n res.clearCookie('better-auth.session_token.sig');\n if (!res.headersSent) {\n res.status(401).json({ error: 'Session expired' });\n }\n return;\n }\n if (!res.headersSent) {\n res.status(500).json({ error: 'Internal server error' });\n }\n }\n };\n\n expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {\n res.json(createMcpProtectedResourceMetadata(configService));\n });\n\n // RFC 8414 – OAuth 2.0 Authorization Server Metadata\n // MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)\n // instead of falling back to the root /register which returns 404.\n expressApp.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {\n const backendOrigin = resolvePublicBackendOrigin(configService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService);\n\n res.json({\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 token_endpoint_auth_methods_supported: ['none'],\n code_challenge_methods_supported: ['S256'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n });\n });\n\n expressApp.all('/api/auth/*splat', lazyAuthHandler);\n expressApp.all('/.well-known/*splat', lazyAuthHandler);\n\n logger.log('Better Auth routes registered (lazy) on /api/auth/* and /.well-known/*');\n\n const port = configService.get<number>('PORT') || 3001;\n await app.listen(port);\n\n logger.log(`Application is running on: http://localhost:${port}`);\n logger.log(`API available at: http://localhost:${port}/api`);\n logger.log('Auth: always enabled (email+password baseline)');\n}\n\nbootstrap();\n"],"names":["Logger","ValidationPipe","ConfigService","NestFactory","toNodeHandler","compression","helmet","AppModule","AllExceptionsFilter","LoggingInterceptor","AuthService","createMcpProtectedResourceMetadata","resolvePublicAuthBaseUrl","resolvePublicBackendOrigin","bootstrap","logLevel","process","env","LOG_LEVEL","logLevels","includes","push","logger","app","create","configService","get","use","_req","res","next","setHeader","corsOrigins","split","enableCors","origin","credentials","methods","setGlobalPrefix","useGlobalPipes","whitelist","forbidNonWhitelisted","transform","transformOptions","enableImplicitConversion","useGlobalFilters","useGlobalInterceptors","authService","expressApp","getHttpAdapter","getInstance","lazyAuthHandler","req","auth","getAuth","error","isPrismaNotFound","Error","code","clearCookie","headersSent","status","json","backendOrigin","authBaseUrl","issuer","authorization_endpoint","token_endpoint","registration_endpoint","jwks_uri","response_types_supported","grant_types_supported","token_endpoint_auth_methods_supported","code_challenge_methods_supported","scopes_supported","all","log","port","listen"],"mappings":"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"}
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Better Auth Configuration
3
3
  *
4
- * Configures Better Auth with Prisma adapter, email+password (always),
5
- * optional Google OAuth, Organization plugin, and MCP OAuth plugin.
4
+ * Configures Better Auth with Prisma adapter, email+password (toggleable via
5
+ * AUTH_EMAIL_PASSWORD env var), optional Google OAuth, Organization plugin,
6
+ * and MCP OAuth plugin.
6
7
  * Auto-creates a default organization on user signup.
7
8
  */ import { ConfigService } from "@nestjs/config";
8
9
  import { betterAuth } from "better-auth";
@@ -17,6 +18,7 @@ import { resolveMcpLoginPageUrl } from "./mcp-oauth.utils.js";
17
18
  }
18
19
  export function createAuth(prisma) {
19
20
  const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
21
+ const emailPasswordEnabled = process.env.AUTH_EMAIL_PASSWORD !== 'false';
20
22
  const configService = new ConfigService();
21
23
  const auth = betterAuth({
22
24
  basePath: '/api/auth',
@@ -30,7 +32,7 @@ export function createAuth(prisma) {
30
32
  provider: 'postgresql'
31
33
  }),
32
34
  emailAndPassword: {
33
- enabled: true
35
+ enabled: emailPasswordEnabled
34
36
  },
35
37
  ...hasGoogle && {
36
38
  socialProviders: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/modules/auth/auth.config.ts"],"sourcesContent":["/**\n * Better Auth Configuration\n *\n * Configures Better Auth with Prisma adapter, email+password (always),\n * optional Google OAuth, Organization plugin, and MCP OAuth plugin.\n * Auto-creates a default organization on user signup.\n */\n\nimport type { PrismaClient } from '@dxheroes/local-mcp-database/generated/prisma';\nimport { ConfigService } from '@nestjs/config';\nimport { betterAuth } from 'better-auth';\nimport { prismaAdapter } from 'better-auth/adapters/prisma';\nimport { mcp } from 'better-auth/plugins';\nimport { organization } from 'better-auth/plugins/organization';\nimport { resolveMcpLoginPageUrl } from './mcp-oauth.utils.js';\n\n/**\n * Auth wrapper — simplified interface to avoid exporting Better Auth's deep generic types.\n */\nexport interface AuthInstance {\n handler: (req: Request) => Promise<Response>;\n api: {\n getSession: (opts: { headers: Headers }) => Promise<{\n user: { id: string; name: string; email: string; image: string | null };\n session: { id: string; userId: string; [key: string]: unknown };\n } | null>;\n };\n}\n\n/**\n * Generate a URL-safe slug from a name.\n */\nfunction toSlug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '');\n}\n\nexport function createAuth(prisma: PrismaClient): AuthInstance {\n const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;\n const configService = new ConfigService();\n\n const auth = betterAuth({\n basePath: '/api/auth',\n baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',\n secret: process.env.BETTER_AUTH_SECRET,\n trustedOrigins: (process.env.CORS_ORIGINS?.split(',') || [\n 'http://localhost:5173',\n 'http://localhost:3000',\n ]),\n database: prismaAdapter(prisma, { provider: 'postgresql' }),\n emailAndPassword: { enabled: true },\n ...(hasGoogle && {\n socialProviders: {\n google: {\n clientId: process.env.GOOGLE_CLIENT_ID ?? '',\n clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',\n },\n },\n }),\n session: {\n cookieCache: {\n enabled: true,\n maxAge: 5 * 60, // 5 minutes\n },\n },\n databaseHooks: {\n user: {\n create: {\n after: async (user) => {\n // Auto-create a default organization for new users\n const orgName = `${user.name}'s workspace`;\n const baseSlug = `${toSlug(user.name)}-workspace`;\n\n // Ensure unique slug\n let slug = baseSlug;\n let suffix = 0;\n while (true) {\n const existing = await prisma.organization.findUnique({ where: { slug } });\n if (!existing) break;\n suffix++;\n slug = `${baseSlug}-${suffix}`;\n }\n\n const orgId = crypto.randomUUID();\n const memberId = crypto.randomUUID();\n\n await prisma.organization.create({\n data: {\n id: orgId,\n name: orgName,\n slug,\n },\n });\n\n await prisma.member.create({\n data: {\n id: memberId,\n organizationId: orgId,\n userId: user.id,\n role: 'owner',\n },\n });\n },\n },\n },\n },\n plugins: [\n organization(),\n mcp({\n loginPage: resolveMcpLoginPageUrl(configService),\n }),\n ],\n });\n return auth as unknown as AuthInstance;\n}\n"],"names":["ConfigService","betterAuth","prismaAdapter","mcp","organization","resolveMcpLoginPageUrl","toSlug","name","toLowerCase","replace","createAuth","prisma","hasGoogle","process","env","GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET","configService","auth","basePath","baseURL","BETTER_AUTH_URL","secret","BETTER_AUTH_SECRET","trustedOrigins","CORS_ORIGINS","split","database","provider","emailAndPassword","enabled","socialProviders","google","clientId","clientSecret","session","cookieCache","maxAge","databaseHooks","user","create","after","orgName","baseSlug","slug","suffix","existing","findUnique","where","orgId","crypto","randomUUID","memberId","data","id","member","organizationId","userId","role","plugins","loginPage"],"mappings":"AAAA;;;;;;CAMC,GAGD,SAASA,aAAa,QAAQ,iBAAiB;AAC/C,SAASC,UAAU,QAAQ,cAAc;AACzC,SAASC,aAAa,QAAQ,8BAA8B;AAC5D,SAASC,GAAG,QAAQ,sBAAsB;AAC1C,SAASC,YAAY,QAAQ,mCAAmC;AAChE,SAASC,sBAAsB,QAAQ,uBAAuB;AAe9D;;CAEC,GACD,SAASC,OAAOC,IAAY;IAC1B,OAAOA,KACJC,WAAW,GACXC,OAAO,CAAC,eAAe,KACvBA,OAAO,CAAC,UAAU;AACvB;AAEA,OAAO,SAASC,WAAWC,MAAoB;IAC7C,MAAMC,YAAY,CAAC,CAACC,QAAQC,GAAG,CAACC,gBAAgB,IAAI,CAAC,CAACF,QAAQC,GAAG,CAACE,oBAAoB;IACtF,MAAMC,gBAAgB,IAAIjB;IAE1B,MAAMkB,OAAOjB,WAAW;QACtBkB,UAAU;QACVC,SAASP,QAAQC,GAAG,CAACO,eAAe,IAAI;QACxCC,QAAQT,QAAQC,GAAG,CAACS,kBAAkB;QACtCC,gBAAiBX,QAAQC,GAAG,CAACW,YAAY,EAAEC,MAAM,QAAQ;YACvD;YACA;SACD;QACDC,UAAUzB,cAAcS,QAAQ;YAAEiB,UAAU;QAAa;QACzDC,kBAAkB;YAAEC,SAAS;QAAK;QAClC,GAAIlB,aAAa;YACfmB,iBAAiB;gBACfC,QAAQ;oBACNC,UAAUpB,QAAQC,GAAG,CAACC,gBAAgB,IAAI;oBAC1CmB,cAAcrB,QAAQC,GAAG,CAACE,oBAAoB,IAAI;gBACpD;YACF;QACF,CAAC;QACDmB,SAAS;YACPC,aAAa;gBACXN,SAAS;gBACTO,QAAQ,IAAI;YACd;QACF;QACAC,eAAe;YACbC,MAAM;gBACJC,QAAQ;oBACNC,OAAO,OAAOF;wBACZ,mDAAmD;wBACnD,MAAMG,UAAU,GAAGH,KAAKhC,IAAI,CAAC,YAAY,CAAC;wBAC1C,MAAMoC,WAAW,GAAGrC,OAAOiC,KAAKhC,IAAI,EAAE,UAAU,CAAC;wBAEjD,qBAAqB;wBACrB,IAAIqC,OAAOD;wBACX,IAAIE,SAAS;wBACb,MAAO,KAAM;4BACX,MAAMC,WAAW,MAAMnC,OAAOP,YAAY,CAAC2C,UAAU,CAAC;gCAAEC,OAAO;oCAAEJ;gCAAK;4BAAE;4BACxE,IAAI,CAACE,UAAU;4BACfD;4BACAD,OAAO,GAAGD,SAAS,CAAC,EAAEE,QAAQ;wBAChC;wBAEA,MAAMI,QAAQC,OAAOC,UAAU;wBAC/B,MAAMC,WAAWF,OAAOC,UAAU;wBAElC,MAAMxC,OAAOP,YAAY,CAACoC,MAAM,CAAC;4BAC/Ba,MAAM;gCACJC,IAAIL;gCACJ1C,MAAMmC;gCACNE;4BACF;wBACF;wBAEA,MAAMjC,OAAO4C,MAAM,CAACf,MAAM,CAAC;4BACzBa,MAAM;gCACJC,IAAIF;gCACJI,gBAAgBP;gCAChBQ,QAAQlB,KAAKe,EAAE;gCACfI,MAAM;4BACR;wBACF;oBACF;gBACF;YACF;QACF;QACAC,SAAS;YACPvD;YACAD,IAAI;gBACFyD,WAAWvD,uBAAuBY;YACpC;SACD;IACH;IACA,OAAOC;AACT"}
1
+ {"version":3,"sources":["../../../src/modules/auth/auth.config.ts"],"sourcesContent":["/**\n * Better Auth Configuration\n *\n * Configures Better Auth with Prisma adapter, email+password (toggleable via\n * AUTH_EMAIL_PASSWORD env var), optional Google OAuth, Organization plugin,\n * and MCP OAuth plugin.\n * Auto-creates a default organization on user signup.\n */\n\nimport type { PrismaClient } from '@dxheroes/local-mcp-database/generated/prisma';\nimport { ConfigService } from '@nestjs/config';\nimport { betterAuth } from 'better-auth';\nimport { prismaAdapter } from 'better-auth/adapters/prisma';\nimport { mcp } from 'better-auth/plugins';\nimport { organization } from 'better-auth/plugins/organization';\nimport { resolveMcpLoginPageUrl } from './mcp-oauth.utils.js';\n\n/**\n * Auth wrapper — simplified interface to avoid exporting Better Auth's deep generic types.\n */\nexport interface AuthInstance {\n handler: (req: Request) => Promise<Response>;\n api: {\n getSession: (opts: { headers: Headers }) => Promise<{\n user: { id: string; name: string; email: string; image: string | null };\n session: { id: string; userId: string; [key: string]: unknown };\n } | null>;\n };\n}\n\n/**\n * Generate a URL-safe slug from a name.\n */\nfunction toSlug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-|-$/g, '');\n}\n\nexport function createAuth(prisma: PrismaClient): AuthInstance {\n const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;\n const emailPasswordEnabled = process.env.AUTH_EMAIL_PASSWORD !== 'false';\n const configService = new ConfigService();\n\n const auth = betterAuth({\n basePath: '/api/auth',\n baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',\n secret: process.env.BETTER_AUTH_SECRET,\n trustedOrigins: (process.env.CORS_ORIGINS?.split(',') || [\n 'http://localhost:5173',\n 'http://localhost:3000',\n ]),\n database: prismaAdapter(prisma, { provider: 'postgresql' }),\n emailAndPassword: { enabled: emailPasswordEnabled },\n ...(hasGoogle && {\n socialProviders: {\n google: {\n clientId: process.env.GOOGLE_CLIENT_ID ?? '',\n clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',\n },\n },\n }),\n session: {\n cookieCache: {\n enabled: true,\n maxAge: 5 * 60, // 5 minutes\n },\n },\n databaseHooks: {\n user: {\n create: {\n after: async (user) => {\n // Auto-create a default organization for new users\n const orgName = `${user.name}'s workspace`;\n const baseSlug = `${toSlug(user.name)}-workspace`;\n\n // Ensure unique slug\n let slug = baseSlug;\n let suffix = 0;\n while (true) {\n const existing = await prisma.organization.findUnique({ where: { slug } });\n if (!existing) break;\n suffix++;\n slug = `${baseSlug}-${suffix}`;\n }\n\n const orgId = crypto.randomUUID();\n const memberId = crypto.randomUUID();\n\n await prisma.organization.create({\n data: {\n id: orgId,\n name: orgName,\n slug,\n },\n });\n\n await prisma.member.create({\n data: {\n id: memberId,\n organizationId: orgId,\n userId: user.id,\n role: 'owner',\n },\n });\n },\n },\n },\n },\n plugins: [\n organization(),\n mcp({\n loginPage: resolveMcpLoginPageUrl(configService),\n }),\n ],\n });\n return auth as unknown as AuthInstance;\n}\n"],"names":["ConfigService","betterAuth","prismaAdapter","mcp","organization","resolveMcpLoginPageUrl","toSlug","name","toLowerCase","replace","createAuth","prisma","hasGoogle","process","env","GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET","emailPasswordEnabled","AUTH_EMAIL_PASSWORD","configService","auth","basePath","baseURL","BETTER_AUTH_URL","secret","BETTER_AUTH_SECRET","trustedOrigins","CORS_ORIGINS","split","database","provider","emailAndPassword","enabled","socialProviders","google","clientId","clientSecret","session","cookieCache","maxAge","databaseHooks","user","create","after","orgName","baseSlug","slug","suffix","existing","findUnique","where","orgId","crypto","randomUUID","memberId","data","id","member","organizationId","userId","role","plugins","loginPage"],"mappings":"AAAA;;;;;;;CAOC,GAGD,SAASA,aAAa,QAAQ,iBAAiB;AAC/C,SAASC,UAAU,QAAQ,cAAc;AACzC,SAASC,aAAa,QAAQ,8BAA8B;AAC5D,SAASC,GAAG,QAAQ,sBAAsB;AAC1C,SAASC,YAAY,QAAQ,mCAAmC;AAChE,SAASC,sBAAsB,QAAQ,uBAAuB;AAe9D;;CAEC,GACD,SAASC,OAAOC,IAAY;IAC1B,OAAOA,KACJC,WAAW,GACXC,OAAO,CAAC,eAAe,KACvBA,OAAO,CAAC,UAAU;AACvB;AAEA,OAAO,SAASC,WAAWC,MAAoB;IAC7C,MAAMC,YAAY,CAAC,CAACC,QAAQC,GAAG,CAACC,gBAAgB,IAAI,CAAC,CAACF,QAAQC,GAAG,CAACE,oBAAoB;IACtF,MAAMC,uBAAuBJ,QAAQC,GAAG,CAACI,mBAAmB,KAAK;IACjE,MAAMC,gBAAgB,IAAInB;IAE1B,MAAMoB,OAAOnB,WAAW;QACtBoB,UAAU;QACVC,SAAST,QAAQC,GAAG,CAACS,eAAe,IAAI;QACxCC,QAAQX,QAAQC,GAAG,CAACW,kBAAkB;QACtCC,gBAAiBb,QAAQC,GAAG,CAACa,YAAY,EAAEC,MAAM,QAAQ;YACvD;YACA;SACD;QACDC,UAAU3B,cAAcS,QAAQ;YAAEmB,UAAU;QAAa;QACzDC,kBAAkB;YAAEC,SAASf;QAAqB;QAClD,GAAIL,aAAa;YACfqB,iBAAiB;gBACfC,QAAQ;oBACNC,UAAUtB,QAAQC,GAAG,CAACC,gBAAgB,IAAI;oBAC1CqB,cAAcvB,QAAQC,GAAG,CAACE,oBAAoB,IAAI;gBACpD;YACF;QACF,CAAC;QACDqB,SAAS;YACPC,aAAa;gBACXN,SAAS;gBACTO,QAAQ,IAAI;YACd;QACF;QACAC,eAAe;YACbC,MAAM;gBACJC,QAAQ;oBACNC,OAAO,OAAOF;wBACZ,mDAAmD;wBACnD,MAAMG,UAAU,GAAGH,KAAKlC,IAAI,CAAC,YAAY,CAAC;wBAC1C,MAAMsC,WAAW,GAAGvC,OAAOmC,KAAKlC,IAAI,EAAE,UAAU,CAAC;wBAEjD,qBAAqB;wBACrB,IAAIuC,OAAOD;wBACX,IAAIE,SAAS;wBACb,MAAO,KAAM;4BACX,MAAMC,WAAW,MAAMrC,OAAOP,YAAY,CAAC6C,UAAU,CAAC;gCAAEC,OAAO;oCAAEJ;gCAAK;4BAAE;4BACxE,IAAI,CAACE,UAAU;4BACfD;4BACAD,OAAO,GAAGD,SAAS,CAAC,EAAEE,QAAQ;wBAChC;wBAEA,MAAMI,QAAQC,OAAOC,UAAU;wBAC/B,MAAMC,WAAWF,OAAOC,UAAU;wBAElC,MAAM1C,OAAOP,YAAY,CAACsC,MAAM,CAAC;4BAC/Ba,MAAM;gCACJC,IAAIL;gCACJ5C,MAAMqC;gCACNE;4BACF;wBACF;wBAEA,MAAMnC,OAAO8C,MAAM,CAACf,MAAM,CAAC;4BACzBa,MAAM;gCACJC,IAAIF;gCACJI,gBAAgBP;gCAChBQ,QAAQlB,KAAKe,EAAE;gCACfI,MAAM;4BACR;wBACF;oBACF;gBACF;YACF;QACF;QACAC,SAAS;YACPzD;YACAD,IAAI;gBACF2D,WAAWzD,uBAAuBc;YACpC;SACD;IACH;IACA,OAAOC;AACT"}
@@ -25,15 +25,40 @@ export class McpOAuthGuard {
25
25
  async canActivate(context) {
26
26
  const request = context.switchToHttp().getRequest();
27
27
  const token = this.extractToken(request);
28
- if (!token) {
29
- throw this.createUnauthorizedError('Bearer token required');
30
- }
31
- const user = await this.authService.validateMcpToken(token);
32
- if (!user) {
28
+ // 1. Try Bearer token (MCP clients like Claude Desktop, Cursor, etc.)
29
+ if (token) {
30
+ const user = await this.authService.validateMcpToken(token);
31
+ if (user) {
32
+ request.user = user;
33
+ return true;
34
+ }
33
35
  throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
34
36
  }
35
- request.user = user;
36
- return true;
37
+ // 2. Try session cookie (browser UI calling /info endpoints)
38
+ const session = await this.validateSessionCookie(request);
39
+ if (session) {
40
+ request.user = session;
41
+ return true;
42
+ }
43
+ // 3. Neither worked — require Bearer token (for MCP client discovery)
44
+ throw this.createUnauthorizedError('Bearer token required');
45
+ }
46
+ /**
47
+ * Attempt to validate session cookie from the request.
48
+ * Returns the user if a valid session exists, null otherwise.
49
+ */ async validateSessionCookie(request) {
50
+ try {
51
+ const headers = new Headers();
52
+ for (const [key, value] of Object.entries(request.headers)){
53
+ if (value) {
54
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
55
+ }
56
+ }
57
+ const result = await this.authService.getSession(headers);
58
+ return result?.user ?? null;
59
+ } catch {
60
+ return null;
61
+ }
37
62
  }
38
63
  /**
39
64
  * Extract Bearer token from Authorization header or access_token query param (SSE fallback).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/modules/auth/mcp-oauth.guard.ts"],"sourcesContent":["/**\n * MCP OAuth Guard\n *\n * Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.\n * Validates Bearer tokens and returns RFC 9728-compliant\n * WWW-Authenticate headers to guide MCP clients through OAuth discovery.\n */\n\nimport {\n CanActivate,\n ExecutionContext,\n Injectable,\n UnauthorizedException,\n} from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { Request } from 'express';\nimport { AuthService } from './auth.service.js';\nimport { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';\n\ntype McpUnauthorizedException = UnauthorizedException & {\n wwwAuthenticate?: string;\n};\n\n@Injectable()\nexport class McpOAuthGuard implements CanActivate {\n constructor(\n private readonly authService: AuthService,\n private readonly configService: ConfigService\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<Request>();\n const token = this.extractToken(request);\n\n if (!token) {\n throw this.createUnauthorizedError('Bearer token required');\n }\n\n const user = await this.authService.validateMcpToken(token);\n if (!user) {\n throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');\n }\n\n request.user = user;\n return true;\n }\n\n /**\n * Extract Bearer token from Authorization header or access_token query param (SSE fallback).\n */\n private extractToken(request: Request): string | null {\n const authHeader = request.headers.authorization;\n if (authHeader?.startsWith('Bearer ')) {\n return authHeader.slice(7);\n }\n\n // SSE connections may pass token as query parameter\n const queryToken = request.query.access_token;\n if (typeof queryToken === 'string' && queryToken.length > 0) {\n return queryToken;\n }\n\n return null;\n }\n\n /**\n * Create UnauthorizedException with RFC 9728 WWW-Authenticate header\n * pointing MCP clients to the protected resource metadata.\n */\n private createUnauthorizedError(message: string): UnauthorizedException {\n const error = new UnauthorizedException(message) as McpUnauthorizedException;\n error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);\n return error;\n }\n}\n"],"names":["Injectable","UnauthorizedException","ConfigService","AuthService","createMcpWwwAuthenticateHeader","McpOAuthGuard","authService","configService","canActivate","context","request","switchToHttp","getRequest","token","extractToken","createUnauthorizedError","user","validateMcpToken","authHeader","headers","authorization","startsWith","slice","queryToken","query","access_token","length","message","error","wwwAuthenticate"],"mappings":";;;;;;;;;AAAA;;;;;;CAMC,GAED,SAGEA,UAAU,EACVC,qBAAqB,QAChB,iBAAiB;AACxB,SAASC,aAAa,QAAQ,iBAAiB;AAE/C,SAASC,WAAW,QAAQ,oBAAoB;AAChD,SAASC,8BAA8B,QAAQ,uBAAuB;AAOtE,OAAO,MAAMC;IACX,YACE,AAAiBC,WAAwB,EACzC,AAAiBC,aAA4B,CAC7C;aAFiBD,cAAAA;aACAC,gBAAAA;IAChB;IAEH,MAAMC,YAAYC,OAAyB,EAAoB;QAC7D,MAAMC,UAAUD,QAAQE,YAAY,GAAGC,UAAU;QACjD,MAAMC,QAAQ,IAAI,CAACC,YAAY,CAACJ;QAEhC,IAAI,CAACG,OAAO;YACV,MAAM,IAAI,CAACE,uBAAuB,CAAC;QACrC;QAEA,MAAMC,OAAO,MAAM,IAAI,CAACV,WAAW,CAACW,gBAAgB,CAACJ;QACrD,IAAI,CAACG,MAAM;YACT,MAAM,IAAI,CAACD,uBAAuB,CAAC;QACrC;QAEAL,QAAQM,IAAI,GAAGA;QACf,OAAO;IACT;IAEA;;GAEC,GACD,AAAQF,aAAaJ,OAAgB,EAAiB;QACpD,MAAMQ,aAAaR,QAAQS,OAAO,CAACC,aAAa;QAChD,IAAIF,YAAYG,WAAW,YAAY;YACrC,OAAOH,WAAWI,KAAK,CAAC;QAC1B;QAEA,oDAAoD;QACpD,MAAMC,aAAab,QAAQc,KAAK,CAACC,YAAY;QAC7C,IAAI,OAAOF,eAAe,YAAYA,WAAWG,MAAM,GAAG,GAAG;YAC3D,OAAOH;QACT;QAEA,OAAO;IACT;IAEA;;;GAGC,GACD,AAAQR,wBAAwBY,OAAe,EAAyB;QACtE,MAAMC,QAAQ,IAAI3B,sBAAsB0B;QACxCC,MAAMC,eAAe,GAAGzB,+BAA+B,IAAI,CAACG,aAAa;QACzE,OAAOqB;IACT;AACF"}
1
+ {"version":3,"sources":["../../../src/modules/auth/mcp-oauth.guard.ts"],"sourcesContent":["/**\n * MCP OAuth Guard\n *\n * Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.\n * Validates Bearer tokens and returns RFC 9728-compliant\n * WWW-Authenticate headers to guide MCP clients through OAuth discovery.\n */\n\nimport {\n CanActivate,\n ExecutionContext,\n Injectable,\n UnauthorizedException,\n} from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport type { Request } from 'express';\nimport { type AuthUser, AuthService } from './auth.service.js';\nimport { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';\n\ntype McpUnauthorizedException = UnauthorizedException & {\n wwwAuthenticate?: string;\n};\n\n@Injectable()\nexport class McpOAuthGuard implements CanActivate {\n constructor(\n private readonly authService: AuthService,\n private readonly configService: ConfigService\n ) {}\n\n async canActivate(context: ExecutionContext): Promise<boolean> {\n const request = context.switchToHttp().getRequest<Request>();\n const token = this.extractToken(request);\n\n // 1. Try Bearer token (MCP clients like Claude Desktop, Cursor, etc.)\n if (token) {\n const user = await this.authService.validateMcpToken(token);\n if (user) {\n request.user = user;\n return true;\n }\n throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');\n }\n\n // 2. Try session cookie (browser UI calling /info endpoints)\n const session = await this.validateSessionCookie(request);\n if (session) {\n request.user = session;\n return true;\n }\n\n // 3. Neither worked — require Bearer token (for MCP client discovery)\n throw this.createUnauthorizedError('Bearer token required');\n }\n\n /**\n * Attempt to validate session cookie from the request.\n * Returns the user if a valid session exists, null otherwise.\n */\n private async validateSessionCookie(request: Request): Promise<AuthUser | null> {\n try {\n const headers = new Headers();\n for (const [key, value] of Object.entries(request.headers)) {\n if (value) {\n headers.set(key, Array.isArray(value) ? value.join(', ') : value);\n }\n }\n const result = await this.authService.getSession(headers);\n return result?.user ?? null;\n } catch {\n return null;\n }\n }\n\n /**\n * Extract Bearer token from Authorization header or access_token query param (SSE fallback).\n */\n private extractToken(request: Request): string | null {\n const authHeader = request.headers.authorization;\n if (authHeader?.startsWith('Bearer ')) {\n return authHeader.slice(7);\n }\n\n // SSE connections may pass token as query parameter\n const queryToken = request.query.access_token;\n if (typeof queryToken === 'string' && queryToken.length > 0) {\n return queryToken;\n }\n\n return null;\n }\n\n /**\n * Create UnauthorizedException with RFC 9728 WWW-Authenticate header\n * pointing MCP clients to the protected resource metadata.\n */\n private createUnauthorizedError(message: string): UnauthorizedException {\n const error = new UnauthorizedException(message) as McpUnauthorizedException;\n error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);\n return error;\n }\n}\n"],"names":["Injectable","UnauthorizedException","ConfigService","AuthService","createMcpWwwAuthenticateHeader","McpOAuthGuard","authService","configService","canActivate","context","request","switchToHttp","getRequest","token","extractToken","user","validateMcpToken","createUnauthorizedError","session","validateSessionCookie","headers","Headers","key","value","Object","entries","set","Array","isArray","join","result","getSession","authHeader","authorization","startsWith","slice","queryToken","query","access_token","length","message","error","wwwAuthenticate"],"mappings":";;;;;;;;;AAAA;;;;;;CAMC,GAED,SAGEA,UAAU,EACVC,qBAAqB,QAChB,iBAAiB;AACxB,SAASC,aAAa,QAAQ,iBAAiB;AAE/C,SAAwBC,WAAW,QAAQ,oBAAoB;AAC/D,SAASC,8BAA8B,QAAQ,uBAAuB;AAOtE,OAAO,MAAMC;IACX,YACE,AAAiBC,WAAwB,EACzC,AAAiBC,aAA4B,CAC7C;aAFiBD,cAAAA;aACAC,gBAAAA;IAChB;IAEH,MAAMC,YAAYC,OAAyB,EAAoB;QAC7D,MAAMC,UAAUD,QAAQE,YAAY,GAAGC,UAAU;QACjD,MAAMC,QAAQ,IAAI,CAACC,YAAY,CAACJ;QAEhC,sEAAsE;QACtE,IAAIG,OAAO;YACT,MAAME,OAAO,MAAM,IAAI,CAACT,WAAW,CAACU,gBAAgB,CAACH;YACrD,IAAIE,MAAM;gBACRL,QAAQK,IAAI,GAAGA;gBACf,OAAO;YACT;YACA,MAAM,IAAI,CAACE,uBAAuB,CAAC;QACrC;QAEA,6DAA6D;QAC7D,MAAMC,UAAU,MAAM,IAAI,CAACC,qBAAqB,CAACT;QACjD,IAAIQ,SAAS;YACXR,QAAQK,IAAI,GAAGG;YACf,OAAO;QACT;QAEA,sEAAsE;QACtE,MAAM,IAAI,CAACD,uBAAuB,CAAC;IACrC;IAEA;;;GAGC,GACD,MAAcE,sBAAsBT,OAAgB,EAA4B;QAC9E,IAAI;YACF,MAAMU,UAAU,IAAIC;YACpB,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACf,QAAQU,OAAO,EAAG;gBAC1D,IAAIG,OAAO;oBACTH,QAAQM,GAAG,CAACJ,KAAKK,MAAMC,OAAO,CAACL,SAASA,MAAMM,IAAI,CAAC,QAAQN;gBAC7D;YACF;YACA,MAAMO,SAAS,MAAM,IAAI,CAACxB,WAAW,CAACyB,UAAU,CAACX;YACjD,OAAOU,QAAQf,QAAQ;QACzB,EAAE,OAAM;YACN,OAAO;QACT;IACF;IAEA;;GAEC,GACD,AAAQD,aAAaJ,OAAgB,EAAiB;QACpD,MAAMsB,aAAatB,QAAQU,OAAO,CAACa,aAAa;QAChD,IAAID,YAAYE,WAAW,YAAY;YACrC,OAAOF,WAAWG,KAAK,CAAC;QAC1B;QAEA,oDAAoD;QACpD,MAAMC,aAAa1B,QAAQ2B,KAAK,CAACC,YAAY;QAC7C,IAAI,OAAOF,eAAe,YAAYA,WAAWG,MAAM,GAAG,GAAG;YAC3D,OAAOH;QACT;QAEA,OAAO;IACT;IAEA;;;GAGC,GACD,AAAQnB,wBAAwBuB,OAAe,EAAyB;QACtE,MAAMC,QAAQ,IAAIxC,sBAAsBuC;QACxCC,MAAMC,eAAe,GAAGtC,+BAA+B,IAAI,CAACG,aAAa;QACzE,OAAOkC;IACT;AACF"}
@@ -31,7 +31,7 @@ export class HealthController {
31
31
  * Returns which auth methods are available.
32
32
  */ getAuthConfig() {
33
33
  return {
34
- emailAndPassword: true,
34
+ emailAndPassword: process.env.AUTH_EMAIL_PASSWORD !== 'false',
35
35
  google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET
36
36
  };
37
37
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/modules/health/health.controller.ts"],"sourcesContent":["/**\n * Health Controller\n *\n * Health check endpoints for monitoring and load balancers.\n */\n\nimport { Controller, Get } from '@nestjs/common';\nimport { Public } from '../auth/decorators/public.decorator.js';\nimport { PrismaService } from '../database/prisma.service.js';\n\n@Public()\n@Controller('health')\nexport class HealthController {\n constructor(private readonly prisma: PrismaService) {}\n\n /**\n * Basic liveness probe\n */\n @Get()\n async getHealth() {\n return {\n status: 'ok',\n timestamp: new Date().toISOString(),\n };\n }\n\n /**\n * Auth configuration for frontend feature detection.\n * Returns which auth methods are available.\n */\n @Get('auth-config')\n getAuthConfig() {\n return {\n emailAndPassword: true,\n google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,\n };\n }\n\n /**\n * Readiness probe with database check\n */\n @Get('ready')\n async getReadiness() {\n try {\n // Test database connectivity\n await this.prisma.$queryRaw`SELECT 1`;\n\n return {\n status: 'ok',\n timestamp: new Date().toISOString(),\n database: 'connected',\n };\n } catch (error) {\n return {\n status: 'error',\n timestamp: new Date().toISOString(),\n database: 'disconnected',\n error: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n}\n"],"names":["Controller","Get","Public","PrismaService","HealthController","prisma","getHealth","status","timestamp","Date","toISOString","getAuthConfig","emailAndPassword","google","process","env","GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET","getReadiness","$queryRaw","database","error","Error","message"],"mappings":";;;;;;;;;AAAA;;;;CAIC,GAED,SAASA,UAAU,EAAEC,GAAG,QAAQ,iBAAiB;AACjD,SAASC,MAAM,QAAQ,yCAAyC;AAChE,SAASC,aAAa,QAAQ,gCAAgC;AAI9D,OAAO,MAAMC;IACX,YAAY,AAAiBC,MAAqB,CAAE;aAAvBA,SAAAA;IAAwB;IAErD;;GAEC,GACD,MACMC,YAAY;QAChB,OAAO;YACLC,QAAQ;YACRC,WAAW,IAAIC,OAAOC,WAAW;QACnC;IACF;IAEA;;;GAGC,GACD,AACAC,gBAAgB;QACd,OAAO;YACLC,kBAAkB;YAClBC,QAAQ,CAAC,CAACC,QAAQC,GAAG,CAACC,gBAAgB,IAAI,CAAC,CAACF,QAAQC,GAAG,CAACE,oBAAoB;QAC9E;IACF;IAEA;;GAEC,GACD,MACMC,eAAe;QACnB,IAAI;YACF,6BAA6B;YAC7B,MAAM,IAAI,CAACb,MAAM,CAACc,SAAS,CAAC,QAAQ,CAAC;YAErC,OAAO;gBACLZ,QAAQ;gBACRC,WAAW,IAAIC,OAAOC,WAAW;gBACjCU,UAAU;YACZ;QACF,EAAE,OAAOC,OAAO;YACd,OAAO;gBACLd,QAAQ;gBACRC,WAAW,IAAIC,OAAOC,WAAW;gBACjCU,UAAU;gBACVC,OAAOA,iBAAiBC,QAAQD,MAAME,OAAO,GAAG;YAClD;QACF;IACF;AACF"}
1
+ {"version":3,"sources":["../../../src/modules/health/health.controller.ts"],"sourcesContent":["/**\n * Health Controller\n *\n * Health check endpoints for monitoring and load balancers.\n */\n\nimport { Controller, Get } from '@nestjs/common';\nimport { Public } from '../auth/decorators/public.decorator.js';\nimport { PrismaService } from '../database/prisma.service.js';\n\n@Public()\n@Controller('health')\nexport class HealthController {\n constructor(private readonly prisma: PrismaService) {}\n\n /**\n * Basic liveness probe\n */\n @Get()\n async getHealth() {\n return {\n status: 'ok',\n timestamp: new Date().toISOString(),\n };\n }\n\n /**\n * Auth configuration for frontend feature detection.\n * Returns which auth methods are available.\n */\n @Get('auth-config')\n getAuthConfig() {\n return {\n emailAndPassword: process.env.AUTH_EMAIL_PASSWORD !== 'false',\n google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,\n };\n }\n\n /**\n * Readiness probe with database check\n */\n @Get('ready')\n async getReadiness() {\n try {\n // Test database connectivity\n await this.prisma.$queryRaw`SELECT 1`;\n\n return {\n status: 'ok',\n timestamp: new Date().toISOString(),\n database: 'connected',\n };\n } catch (error) {\n return {\n status: 'error',\n timestamp: new Date().toISOString(),\n database: 'disconnected',\n error: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n}\n"],"names":["Controller","Get","Public","PrismaService","HealthController","prisma","getHealth","status","timestamp","Date","toISOString","getAuthConfig","emailAndPassword","process","env","AUTH_EMAIL_PASSWORD","google","GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET","getReadiness","$queryRaw","database","error","Error","message"],"mappings":";;;;;;;;;AAAA;;;;CAIC,GAED,SAASA,UAAU,EAAEC,GAAG,QAAQ,iBAAiB;AACjD,SAASC,MAAM,QAAQ,yCAAyC;AAChE,SAASC,aAAa,QAAQ,gCAAgC;AAI9D,OAAO,MAAMC;IACX,YAAY,AAAiBC,MAAqB,CAAE;aAAvBA,SAAAA;IAAwB;IAErD;;GAEC,GACD,MACMC,YAAY;QAChB,OAAO;YACLC,QAAQ;YACRC,WAAW,IAAIC,OAAOC,WAAW;QACnC;IACF;IAEA;;;GAGC,GACD,AACAC,gBAAgB;QACd,OAAO;YACLC,kBAAkBC,QAAQC,GAAG,CAACC,mBAAmB,KAAK;YACtDC,QAAQ,CAAC,CAACH,QAAQC,GAAG,CAACG,gBAAgB,IAAI,CAAC,CAACJ,QAAQC,GAAG,CAACI,oBAAoB;QAC9E;IACF;IAEA;;GAEC,GACD,MACMC,eAAe;QACnB,IAAI;YACF,6BAA6B;YAC7B,MAAM,IAAI,CAACd,MAAM,CAACe,SAAS,CAAC,QAAQ,CAAC;YAErC,OAAO;gBACLb,QAAQ;gBACRC,WAAW,IAAIC,OAAOC,WAAW;gBACjCW,UAAU;YACZ;QACF,EAAE,OAAOC,OAAO;YACd,OAAO;gBACLf,QAAQ;gBACRC,WAAW,IAAIC,OAAOC,WAAW;gBACjCW,UAAU;gBACVC,OAAOA,iBAAiBC,QAAQD,MAAME,OAAO,GAAG;YAClD;QACF;IACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxheroes/local-mcp-backend",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "NestJS API server providing MCP proxy, server aggregation, OAuth 2.1, and profile management",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -23,11 +23,13 @@
23
23
  "reflect-metadata": "^0.2.2",
24
24
  "rxjs": "^7.8.2",
25
25
  "zod": "^4.3.5",
26
- "@dxheroes/local-mcp-core": "0.8.0",
27
- "@dxheroes/local-mcp-database": "0.5.3",
28
- "@dxheroes/mcp-gemini-deep-research": "0.5.6",
29
- "@dxheroes/mcp-merk": "0.3.6",
30
- "@dxheroes/mcp-toggl": "0.3.6"
26
+ "@dxheroes/local-mcp-database": "0.5.4",
27
+ "@dxheroes/mcp-abra-flexi": "0.3.3",
28
+ "@dxheroes/local-mcp-core": "0.8.1",
29
+ "@dxheroes/mcp-gemini-deep-research": "0.5.7",
30
+ "@dxheroes/mcp-fakturoid": "0.3.3",
31
+ "@dxheroes/mcp-toggl": "0.3.7",
32
+ "@dxheroes/mcp-merk": "0.3.7"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@nestjs/cli": "^11.0.14",
@@ -41,7 +43,7 @@
41
43
  "@types/pg": "^8.18.0",
42
44
  "typescript": "^5.9.3",
43
45
  "vitest": "^4.0.17",
44
- "@dxheroes/local-mcp-config": "0.4.11"
46
+ "@dxheroes/local-mcp-config": "0.4.12"
45
47
  },
46
48
  "scripts": {
47
49
  "build": "nest build",
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  type MockAuthService = {
14
14
  validateMcpToken: ReturnType<typeof vi.fn>;
15
+ getSession: ReturnType<typeof vi.fn>;
15
16
  };
16
17
 
17
18
  type MockConfigService = {
@@ -27,6 +28,7 @@ function createConfigService(values: Record<string, unknown>): MockConfigService
27
28
  function createAuthServiceMock(): MockAuthService {
28
29
  return {
29
30
  validateMcpToken: vi.fn().mockResolvedValue(null),
31
+ getSession: vi.fn().mockResolvedValue(null),
30
32
  };
31
33
  }
32
34
 
@@ -278,4 +280,32 @@ describe('MCP proxy auth HTTP contract', () => {
278
280
  });
279
281
  expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
280
282
  });
283
+
284
+ it('accepts session cookie when no Bearer token is present', async () => {
285
+ authService.getSession.mockResolvedValue({
286
+ user: { id: 'cookie-user', name: 'Cookie User', email: 'cookie@example.com' },
287
+ session: { id: 'sess-1', userId: 'cookie-user' },
288
+ });
289
+
290
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
291
+ headers: { cookie: 'better-auth.session_token=valid-session' },
292
+ });
293
+ const body = (await response.json()) as Record<string, unknown>;
294
+
295
+ expect(response.status).toBe(200);
296
+ expect(body).toEqual({ ok: true, userId: 'cookie-user' });
297
+ expect(authService.getSession).toHaveBeenCalled();
298
+ });
299
+
300
+ it('returns 401 when session cookie is invalid and no Bearer token', async () => {
301
+ authService.getSession.mockResolvedValue(null);
302
+
303
+ const response = await fetch(`${baseUrl}/api/mcp/test`, {
304
+ headers: { cookie: 'better-auth.session_token=expired' },
305
+ });
306
+ const body = (await response.json()) as Record<string, unknown>;
307
+
308
+ expect(response.status).toBe(401);
309
+ expect(body.message).toBe('Bearer token required');
310
+ });
281
311
  });
@@ -174,6 +174,38 @@ describe('McpOAuthGuard', () => {
174
174
  expect(result).toBe(true);
175
175
  expect(authService.validateMcpToken).toHaveBeenCalledWith('sse-token');
176
176
  });
177
+
178
+ it('accepts session cookie when no Bearer token is present', async () => {
179
+ const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };
180
+ authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });
181
+ const req = createMockRequest({ cookie: 'better-auth.session_token=abc123' });
182
+ const ctx = createMockExecutionContext(req);
183
+
184
+ const result = await guard.canActivate(ctx);
185
+
186
+ expect(result).toBe(true);
187
+ expect(req.user).toEqual(user);
188
+ expect(authService.validateMcpToken).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it('rejects when neither Bearer token nor session cookie is valid', async () => {
192
+ authService.getSession.mockResolvedValue(null);
193
+ const req = createMockRequest({ cookie: 'better-auth.session_token=expired' });
194
+ const ctx = createMockExecutionContext(req);
195
+
196
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
197
+ });
198
+
199
+ it('does not fall through to session cookie when Bearer token is invalid', async () => {
200
+ const user: AuthUser = { id: 'session-user', name: 'Session', email: 'session@example.com' };
201
+ authService.validateMcpToken.mockResolvedValue(null);
202
+ authService.getSession.mockResolvedValue({ user, session: { id: 's1', userId: user.id } });
203
+ const req = createMockRequest({ authorization: 'Bearer bad-token' });
204
+ const ctx = createMockExecutionContext(req);
205
+
206
+ await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
207
+ expect(authService.getSession).not.toHaveBeenCalled();
208
+ });
177
209
  });
178
210
 
179
211
  // ────────────────────────────────────────────────
package/src/main.ts CHANGED
@@ -40,6 +40,10 @@ async function bootstrap() {
40
40
 
41
41
  // Security
42
42
  app.use(helmet());
43
+ app.use((_req: Request, res: Response, next: NextFunction) => {
44
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
45
+ next();
46
+ });
43
47
  app.use(compression());
44
48
 
45
49
  // CORS
@@ -78,10 +82,29 @@ async function bootstrap() {
78
82
  const authService = app.get(AuthService);
79
83
  const expressApp = app.getHttpAdapter().getInstance();
80
84
 
81
- const lazyAuthHandler = (req: Request, res: Response, next: NextFunction) => {
85
+ const lazyAuthHandler = async (req: Request, res: Response, next: NextFunction) => {
82
86
  const auth = authService.getAuth();
83
87
  if (!auth) return next();
84
- toNodeHandler(auth)(req, res);
88
+ try {
89
+ await toNodeHandler(auth)(req, res);
90
+ } catch (error: unknown) {
91
+ const isPrismaNotFound =
92
+ error instanceof Error &&
93
+ 'code' in error &&
94
+ (error as { code: string }).code === 'P2025';
95
+ if (isPrismaNotFound) {
96
+ // Stale session cookie — clear it and return 401
97
+ res.clearCookie('better-auth.session_token');
98
+ res.clearCookie('better-auth.session_token.sig');
99
+ if (!res.headersSent) {
100
+ res.status(401).json({ error: 'Session expired' });
101
+ }
102
+ return;
103
+ }
104
+ if (!res.headersSent) {
105
+ res.status(500).json({ error: 'Internal server error' });
106
+ }
107
+ }
85
108
  };
86
109
 
87
110
  expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Better Auth Configuration
3
3
  *
4
- * Configures Better Auth with Prisma adapter, email+password (always),
5
- * optional Google OAuth, Organization plugin, and MCP OAuth plugin.
4
+ * Configures Better Auth with Prisma adapter, email+password (toggleable via
5
+ * AUTH_EMAIL_PASSWORD env var), optional Google OAuth, Organization plugin,
6
+ * and MCP OAuth plugin.
6
7
  * Auto-creates a default organization on user signup.
7
8
  */
8
9
 
@@ -39,6 +40,7 @@ function toSlug(name: string): string {
39
40
 
40
41
  export function createAuth(prisma: PrismaClient): AuthInstance {
41
42
  const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
43
+ const emailPasswordEnabled = process.env.AUTH_EMAIL_PASSWORD !== 'false';
42
44
  const configService = new ConfigService();
43
45
 
44
46
  const auth = betterAuth({
@@ -50,7 +52,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
50
52
  'http://localhost:3000',
51
53
  ]),
52
54
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
53
- emailAndPassword: { enabled: true },
55
+ emailAndPassword: { enabled: emailPasswordEnabled },
54
56
  ...(hasGoogle && {
55
57
  socialProviders: {
56
58
  google: {
@@ -14,7 +14,7 @@ import {
14
14
  } from '@nestjs/common';
15
15
  import { ConfigService } from '@nestjs/config';
16
16
  import type { Request } from 'express';
17
- import { AuthService } from './auth.service.js';
17
+ import { type AuthUser, AuthService } from './auth.service.js';
18
18
  import { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';
19
19
 
20
20
  type McpUnauthorizedException = UnauthorizedException & {
@@ -32,17 +32,44 @@ export class McpOAuthGuard implements CanActivate {
32
32
  const request = context.switchToHttp().getRequest<Request>();
33
33
  const token = this.extractToken(request);
34
34
 
35
- if (!token) {
36
- throw this.createUnauthorizedError('Bearer token required');
35
+ // 1. Try Bearer token (MCP clients like Claude Desktop, Cursor, etc.)
36
+ if (token) {
37
+ const user = await this.authService.validateMcpToken(token);
38
+ if (user) {
39
+ request.user = user;
40
+ return true;
41
+ }
42
+ throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
37
43
  }
38
44
 
39
- const user = await this.authService.validateMcpToken(token);
40
- if (!user) {
41
- throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
45
+ // 2. Try session cookie (browser UI calling /info endpoints)
46
+ const session = await this.validateSessionCookie(request);
47
+ if (session) {
48
+ request.user = session;
49
+ return true;
42
50
  }
43
51
 
44
- request.user = user;
45
- return true;
52
+ // 3. Neither worked — require Bearer token (for MCP client discovery)
53
+ throw this.createUnauthorizedError('Bearer token required');
54
+ }
55
+
56
+ /**
57
+ * Attempt to validate session cookie from the request.
58
+ * Returns the user if a valid session exists, null otherwise.
59
+ */
60
+ private async validateSessionCookie(request: Request): Promise<AuthUser | null> {
61
+ try {
62
+ const headers = new Headers();
63
+ for (const [key, value] of Object.entries(request.headers)) {
64
+ if (value) {
65
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
66
+ }
67
+ }
68
+ const result = await this.authService.getSession(headers);
69
+ return result?.user ?? null;
70
+ } catch {
71
+ return null;
72
+ }
46
73
  }
47
74
 
48
75
  /**
@@ -31,7 +31,7 @@ export class HealthController {
31
31
  @Get('auth-config')
32
32
  getAuthConfig() {
33
33
  return {
34
- emailAndPassword: true,
34
+ emailAndPassword: process.env.AUTH_EMAIL_PASSWORD !== 'false',
35
35
  google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
36
36
  };
37
37
  }