@dxheroes/local-mcp-backend 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +27 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +246 -0
- package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
- package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
- package/dist/__tests__/integration/proxy-auth.test.js +121 -111
- package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
- package/dist/__tests__/unit/auth.guard.test.js +23 -2
- package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
- package/dist/common/filters/all-exceptions.filter.js +6 -0
- package/dist/common/filters/all-exceptions.filter.js.map +1 -1
- package/dist/main.js +37 -0
- package/dist/main.js.map +1 -1
- package/dist/modules/auth/auth.config.js +5 -2
- package/dist/modules/auth/auth.config.js.map +1 -1
- package/dist/modules/auth/auth.module.js +5 -2
- package/dist/modules/auth/auth.module.js.map +1 -1
- package/dist/modules/auth/auth.service.js +2 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/mcp-oauth.guard.js +70 -0
- package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
- package/dist/modules/auth/mcp-oauth.utils.js +75 -0
- package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
- package/dist/modules/mcp/mcp.service.js +48 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/oauth/oauth.controller.js +78 -1
- package/dist/modules/oauth/oauth.controller.js.map +1 -1
- package/dist/modules/oauth/oauth.service.js +197 -1
- package/dist/modules/oauth/oauth.service.js.map +1 -1
- package/dist/modules/proxy/proxy.controller.js +152 -27
- package/dist/modules/proxy/proxy.controller.js.map +1 -1
- package/dist/modules/proxy/proxy.service.js +28 -4
- package/dist/modules/proxy/proxy.service.js.map +1 -1
- package/docker-entrypoint.sh +15 -2
- package/package.json +7 -7
- package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +281 -0
- package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
- package/src/__tests__/integration/proxy-auth.test.ts +119 -168
- package/src/__tests__/unit/auth.guard.test.ts +12 -2
- package/src/common/filters/all-exceptions.filter.ts +11 -0
- package/src/main.ts +32 -1
- package/src/modules/auth/auth.config.ts +4 -1
- package/src/modules/auth/auth.module.ts +3 -2
- package/src/modules/auth/auth.service.ts +2 -2
- package/src/modules/auth/mcp-oauth.guard.ts +75 -0
- package/src/modules/auth/mcp-oauth.utils.ts +80 -0
- package/src/modules/mcp/mcp.service.ts +54 -12
- package/src/modules/oauth/oauth.controller.ts +84 -1
- package/src/modules/oauth/oauth.service.ts +218 -1
- package/src/modules/proxy/proxy.controller.ts +120 -25
- package/src/modules/proxy/proxy.service.ts +26 -4
- package/vitest.config.ts +2 -1
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 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';\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:
|
|
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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"}
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Configures Better Auth with Prisma adapter, email+password (always),
|
|
5
5
|
* optional Google OAuth, Organization plugin, and MCP OAuth plugin.
|
|
6
6
|
* Auto-creates a default organization on user signup.
|
|
7
|
-
*/ import {
|
|
7
|
+
*/ import { ConfigService } from "@nestjs/config";
|
|
8
|
+
import { betterAuth } from "better-auth";
|
|
8
9
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
9
10
|
import { mcp } from "better-auth/plugins";
|
|
10
11
|
import { organization } from "better-auth/plugins/organization";
|
|
12
|
+
import { resolveMcpLoginPageUrl } from "./mcp-oauth.utils.js";
|
|
11
13
|
/**
|
|
12
14
|
* Generate a URL-safe slug from a name.
|
|
13
15
|
*/ function toSlug(name) {
|
|
@@ -15,6 +17,7 @@ import { organization } from "better-auth/plugins/organization";
|
|
|
15
17
|
}
|
|
16
18
|
export function createAuth(prisma) {
|
|
17
19
|
const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
|
|
20
|
+
const configService = new ConfigService();
|
|
18
21
|
const auth = betterAuth({
|
|
19
22
|
basePath: '/api/auth',
|
|
20
23
|
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',
|
|
@@ -87,7 +90,7 @@ export function createAuth(prisma) {
|
|
|
87
90
|
plugins: [
|
|
88
91
|
organization(),
|
|
89
92
|
mcp({
|
|
90
|
-
loginPage:
|
|
93
|
+
loginPage: resolveMcpLoginPageUrl(configService)
|
|
91
94
|
})
|
|
92
95
|
]
|
|
93
96
|
});
|
|
@@ -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 { betterAuth } from 'better-auth';\nimport { prismaAdapter } from 'better-auth/adapters/prisma';\nimport { mcp } from 'better-auth/plugins';\nimport { organization } from 'better-auth/plugins/organization';\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\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:
|
|
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"}
|
|
@@ -11,6 +11,7 @@ function _ts_decorate(decorators, target, key, desc) {
|
|
|
11
11
|
*/ import { Global, Module } from "@nestjs/common";
|
|
12
12
|
import { AuthGuard } from "./auth.guard.js";
|
|
13
13
|
import { AuthService } from "./auth.service.js";
|
|
14
|
+
import { McpOAuthGuard } from "./mcp-oauth.guard.js";
|
|
14
15
|
export class AuthModule {
|
|
15
16
|
}
|
|
16
17
|
AuthModule = _ts_decorate([
|
|
@@ -18,11 +19,13 @@ AuthModule = _ts_decorate([
|
|
|
18
19
|
Module({
|
|
19
20
|
providers: [
|
|
20
21
|
AuthService,
|
|
21
|
-
AuthGuard
|
|
22
|
+
AuthGuard,
|
|
23
|
+
McpOAuthGuard
|
|
22
24
|
],
|
|
23
25
|
exports: [
|
|
24
26
|
AuthService,
|
|
25
|
-
AuthGuard
|
|
27
|
+
AuthGuard,
|
|
28
|
+
McpOAuthGuard
|
|
26
29
|
]
|
|
27
30
|
})
|
|
28
31
|
], AuthModule);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/modules/auth/auth.module.ts"],"sourcesContent":["/**\n * Auth Module\n *\n * Global module providing authentication via Better Auth.\n */\n\nimport { Global, Module } from '@nestjs/common';\nimport { AuthGuard } from './auth.guard.js';\nimport { AuthService } from './auth.service.js';\n\n@Global()\n@Module({\n providers: [AuthService, AuthGuard],\n exports: [AuthService, AuthGuard],\n})\nexport class AuthModule {}\n"],"names":["Global","Module","AuthGuard","AuthService","AuthModule","providers","exports"],"mappings":";;;;;;AAAA;;;;CAIC,GAED,SAASA,MAAM,EAAEC,MAAM,QAAQ,iBAAiB;AAChD,SAASC,SAAS,QAAQ,kBAAkB;AAC5C,SAASC,WAAW,QAAQ,oBAAoB;
|
|
1
|
+
{"version":3,"sources":["../../../src/modules/auth/auth.module.ts"],"sourcesContent":["/**\n * Auth Module\n *\n * Global module providing authentication via Better Auth.\n */\n\nimport { Global, Module } from '@nestjs/common';\nimport { AuthGuard } from './auth.guard.js';\nimport { AuthService } from './auth.service.js';\nimport { McpOAuthGuard } from './mcp-oauth.guard.js';\n\n@Global()\n@Module({\n providers: [AuthService, AuthGuard, McpOAuthGuard],\n exports: [AuthService, AuthGuard, McpOAuthGuard],\n})\nexport class AuthModule {}\n"],"names":["Global","Module","AuthGuard","AuthService","McpOAuthGuard","AuthModule","providers","exports"],"mappings":";;;;;;AAAA;;;;CAIC,GAED,SAASA,MAAM,EAAEC,MAAM,QAAQ,iBAAiB;AAChD,SAASC,SAAS,QAAQ,kBAAkB;AAC5C,SAASC,WAAW,QAAQ,oBAAoB;AAChD,SAASC,aAAa,QAAQ,uBAAuB;AAOrD,OAAO,MAAMC;AAAY;;;;QAHvBC,WAAW;YAACH;YAAaD;YAAWE;SAAc;QAClDG,SAAS;YAACJ;YAAaD;YAAWE;SAAc"}
|
|
@@ -80,12 +80,12 @@ export class AuthService {
|
|
|
80
80
|
try {
|
|
81
81
|
const tokenRecord = await this.prisma.oauthAccessToken.findFirst({
|
|
82
82
|
where: {
|
|
83
|
-
|
|
83
|
+
accessToken: bearerToken
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
if (!tokenRecord || !tokenRecord.userId) return null;
|
|
87
87
|
// Check expiration
|
|
88
|
-
if (
|
|
88
|
+
if (new Date(tokenRecord.accessTokenExpiresAt) < new Date()) {
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
// Resolve user
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/modules/auth/auth.service.ts"],"sourcesContent":["/**\n * Auth Service\n *\n * Wraps Better Auth instance for use in NestJS services and guards.\n * Auth is always enabled with email+password as baseline.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { Injectable, Logger, OnModuleInit } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { PrismaService } from '../database/prisma.service.js';\nimport { type AuthInstance, createAuth } from './auth.config.js';\n\nexport interface AuthUser {\n id: string;\n name: string;\n email: string;\n image?: string | null;\n}\n\nexport interface AuthSession {\n id: string;\n userId: string;\n activeOrganizationId?: string | null;\n}\n\n@Injectable()\nexport class AuthService implements OnModuleInit {\n private readonly logger = new Logger(AuthService.name);\n private auth!: AuthInstance;\n\n constructor(\n private readonly prisma: PrismaService,\n private readonly configService: ConfigService\n ) {}\n\n onModuleInit() {\n if (!this.configService.get<string>('BETTER_AUTH_SECRET')) {\n const generated = randomUUID() + randomUUID();\n process.env.BETTER_AUTH_SECRET = generated;\n this.logger.warn(\n 'BETTER_AUTH_SECRET not set — using auto-generated secret. ' +\n 'Sessions will be invalidated on restart. Set BETTER_AUTH_SECRET in .env for persistence.'\n );\n }\n try {\n this.auth = createAuth(this.prisma);\n this.logger.log('Auth initialized (always enabled)');\n } catch (error) {\n this.logger.error(\n 'Failed to initialize Better Auth. Check database connectivity and migrations.',\n error instanceof Error ? error.stack : String(error)\n );\n throw error;\n }\n }\n\n /** Get the raw Better Auth instance (for mounting handler). */\n getAuth(): AuthInstance {\n return this.auth;\n }\n\n /** Validate a session from request headers (cookie-based) */\n async getSession(headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> {\n try {\n const result = await this.auth.api.getSession({ headers });\n if (!result) return null;\n return {\n user: {\n id: result.user.id,\n name: result.user.name,\n email: result.user.email,\n image: result.user.image,\n },\n session: {\n id: result.session.id,\n userId: result.session.userId,\n activeOrganizationId: (result.session as Record<string, unknown>).activeOrganizationId as\n | string\n | null\n | undefined,\n },\n };\n } catch {\n return null;\n }\n }\n\n /** Get organizations a user belongs to */\n async getUserOrganizations(userId: string) {\n return this.prisma.member.findMany({\n where: { userId },\n include: { organization: true },\n });\n }\n\n /**\n * Validate an MCP OAuth Bearer token from the proxy endpoint.\n * Returns the user if the token is valid, null otherwise.\n */\n async validateMcpToken(bearerToken: string): Promise<AuthUser | null> {\n try {\n const tokenRecord = await this.prisma.oauthAccessToken.findFirst({\n where: {
|
|
1
|
+
{"version":3,"sources":["../../../src/modules/auth/auth.service.ts"],"sourcesContent":["/**\n * Auth Service\n *\n * Wraps Better Auth instance for use in NestJS services and guards.\n * Auth is always enabled with email+password as baseline.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { Injectable, Logger, OnModuleInit } from '@nestjs/common';\nimport { ConfigService } from '@nestjs/config';\nimport { PrismaService } from '../database/prisma.service.js';\nimport { type AuthInstance, createAuth } from './auth.config.js';\n\nexport interface AuthUser {\n id: string;\n name: string;\n email: string;\n image?: string | null;\n}\n\nexport interface AuthSession {\n id: string;\n userId: string;\n activeOrganizationId?: string | null;\n}\n\n@Injectable()\nexport class AuthService implements OnModuleInit {\n private readonly logger = new Logger(AuthService.name);\n private auth!: AuthInstance;\n\n constructor(\n private readonly prisma: PrismaService,\n private readonly configService: ConfigService\n ) {}\n\n onModuleInit() {\n if (!this.configService.get<string>('BETTER_AUTH_SECRET')) {\n const generated = randomUUID() + randomUUID();\n process.env.BETTER_AUTH_SECRET = generated;\n this.logger.warn(\n 'BETTER_AUTH_SECRET not set — using auto-generated secret. ' +\n 'Sessions will be invalidated on restart. Set BETTER_AUTH_SECRET in .env for persistence.'\n );\n }\n try {\n this.auth = createAuth(this.prisma);\n this.logger.log('Auth initialized (always enabled)');\n } catch (error) {\n this.logger.error(\n 'Failed to initialize Better Auth. Check database connectivity and migrations.',\n error instanceof Error ? error.stack : String(error)\n );\n throw error;\n }\n }\n\n /** Get the raw Better Auth instance (for mounting handler). */\n getAuth(): AuthInstance {\n return this.auth;\n }\n\n /** Validate a session from request headers (cookie-based) */\n async getSession(headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> {\n try {\n const result = await this.auth.api.getSession({ headers });\n if (!result) return null;\n return {\n user: {\n id: result.user.id,\n name: result.user.name,\n email: result.user.email,\n image: result.user.image,\n },\n session: {\n id: result.session.id,\n userId: result.session.userId,\n activeOrganizationId: (result.session as Record<string, unknown>).activeOrganizationId as\n | string\n | null\n | undefined,\n },\n };\n } catch {\n return null;\n }\n }\n\n /** Get organizations a user belongs to */\n async getUserOrganizations(userId: string) {\n return this.prisma.member.findMany({\n where: { userId },\n include: { organization: true },\n });\n }\n\n /**\n * Validate an MCP OAuth Bearer token from the proxy endpoint.\n * Returns the user if the token is valid, null otherwise.\n */\n async validateMcpToken(bearerToken: string): Promise<AuthUser | null> {\n try {\n const tokenRecord = await this.prisma.oauthAccessToken.findFirst({\n where: { accessToken: bearerToken },\n });\n\n if (!tokenRecord || !tokenRecord.userId) return null;\n\n // Check expiration\n if (new Date(tokenRecord.accessTokenExpiresAt) < new Date()) {\n return null;\n }\n\n // Resolve user\n const user = await this.prisma.user.findUnique({\n where: { id: tokenRecord.userId },\n });\n\n if (!user) return null;\n\n return {\n id: user.id,\n name: user.name,\n email: user.email,\n image: user.image,\n };\n } catch {\n return null;\n }\n }\n}\n"],"names":["randomUUID","Injectable","Logger","ConfigService","PrismaService","createAuth","AuthService","prisma","configService","logger","name","onModuleInit","get","generated","process","env","BETTER_AUTH_SECRET","warn","auth","log","error","Error","stack","String","getAuth","getSession","headers","result","api","user","id","email","image","session","userId","activeOrganizationId","getUserOrganizations","member","findMany","where","include","organization","validateMcpToken","bearerToken","tokenRecord","oauthAccessToken","findFirst","accessToken","Date","accessTokenExpiresAt","findUnique"],"mappings":"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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
}
|
|
7
|
+
function _ts_metadata(k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* MCP OAuth Guard
|
|
12
|
+
*
|
|
13
|
+
* Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.
|
|
14
|
+
* Validates Bearer tokens and returns RFC 9728-compliant
|
|
15
|
+
* WWW-Authenticate headers to guide MCP clients through OAuth discovery.
|
|
16
|
+
*/ import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
17
|
+
import { ConfigService } from "@nestjs/config";
|
|
18
|
+
import { AuthService } from "./auth.service.js";
|
|
19
|
+
import { createMcpWwwAuthenticateHeader } from "./mcp-oauth.utils.js";
|
|
20
|
+
export class McpOAuthGuard {
|
|
21
|
+
constructor(authService, configService){
|
|
22
|
+
this.authService = authService;
|
|
23
|
+
this.configService = configService;
|
|
24
|
+
}
|
|
25
|
+
async canActivate(context) {
|
|
26
|
+
const request = context.switchToHttp().getRequest();
|
|
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) {
|
|
33
|
+
throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
|
|
34
|
+
}
|
|
35
|
+
request.user = user;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract Bearer token from Authorization header or access_token query param (SSE fallback).
|
|
40
|
+
*/ extractToken(request) {
|
|
41
|
+
const authHeader = request.headers.authorization;
|
|
42
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
43
|
+
return authHeader.slice(7);
|
|
44
|
+
}
|
|
45
|
+
// SSE connections may pass token as query parameter
|
|
46
|
+
const queryToken = request.query.access_token;
|
|
47
|
+
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
|
48
|
+
return queryToken;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create UnauthorizedException with RFC 9728 WWW-Authenticate header
|
|
54
|
+
* pointing MCP clients to the protected resource metadata.
|
|
55
|
+
*/ createUnauthorizedError(message) {
|
|
56
|
+
const error = new UnauthorizedException(message);
|
|
57
|
+
error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);
|
|
58
|
+
return error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
McpOAuthGuard = _ts_decorate([
|
|
62
|
+
Injectable(),
|
|
63
|
+
_ts_metadata("design:type", Function),
|
|
64
|
+
_ts_metadata("design:paramtypes", [
|
|
65
|
+
typeof AuthService === "undefined" ? Object : AuthService,
|
|
66
|
+
typeof ConfigService === "undefined" ? Object : ConfigService
|
|
67
|
+
])
|
|
68
|
+
], McpOAuthGuard);
|
|
69
|
+
|
|
70
|
+
//# sourceMappingURL=mcp-oauth.guard.js.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const DEFAULT_BACKEND_PORT = '3001';
|
|
2
|
+
const DEFAULT_FRONTEND_PORT = '3000';
|
|
3
|
+
function trimTrailingSlash(value) {
|
|
4
|
+
return value.replace(/\/+$/, '');
|
|
5
|
+
}
|
|
6
|
+
function swapPort(origin, fromPort, toPort) {
|
|
7
|
+
const url = new URL(origin);
|
|
8
|
+
if (url.port === fromPort) {
|
|
9
|
+
url.port = toPort;
|
|
10
|
+
}
|
|
11
|
+
return trimTrailingSlash(url.origin);
|
|
12
|
+
}
|
|
13
|
+
export function resolvePublicBackendOrigin(configService) {
|
|
14
|
+
const configuredUrl = configService.get('BETTER_AUTH_URL');
|
|
15
|
+
if (configuredUrl) {
|
|
16
|
+
return trimTrailingSlash(new URL(configuredUrl).origin);
|
|
17
|
+
}
|
|
18
|
+
const port = configService.get('app.port') || Number(DEFAULT_BACKEND_PORT);
|
|
19
|
+
return `http://localhost:${port}`;
|
|
20
|
+
}
|
|
21
|
+
export function resolvePublicAuthBaseUrl(configService) {
|
|
22
|
+
return `${resolvePublicBackendOrigin(configService)}/api/auth`;
|
|
23
|
+
}
|
|
24
|
+
export function resolveFrontendOrigin(configService) {
|
|
25
|
+
const configuredUrl = configService.get('FRONTEND_URL');
|
|
26
|
+
if (configuredUrl) {
|
|
27
|
+
return trimTrailingSlash(new URL(configuredUrl).origin);
|
|
28
|
+
}
|
|
29
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
30
|
+
const backendUrl = new URL(backendOrigin);
|
|
31
|
+
if (backendUrl.port === '9631') {
|
|
32
|
+
return swapPort(backendOrigin, '9631', '9630');
|
|
33
|
+
}
|
|
34
|
+
if (backendUrl.port === DEFAULT_BACKEND_PORT) {
|
|
35
|
+
return swapPort(backendOrigin, DEFAULT_BACKEND_PORT, DEFAULT_FRONTEND_PORT);
|
|
36
|
+
}
|
|
37
|
+
return trimTrailingSlash(backendUrl.origin);
|
|
38
|
+
}
|
|
39
|
+
export function resolveMcpLoginPageUrl(configService) {
|
|
40
|
+
return `${resolveFrontendOrigin(configService)}/sign-in`;
|
|
41
|
+
}
|
|
42
|
+
export function createMcpProtectedResourceMetadata(configService) {
|
|
43
|
+
const backendOrigin = resolvePublicBackendOrigin(configService);
|
|
44
|
+
const authBaseUrl = resolvePublicAuthBaseUrl(configService);
|
|
45
|
+
return {
|
|
46
|
+
resource: `${backendOrigin}/api/mcp`,
|
|
47
|
+
authorization_servers: [
|
|
48
|
+
backendOrigin
|
|
49
|
+
],
|
|
50
|
+
bearer_methods_supported: [
|
|
51
|
+
'header'
|
|
52
|
+
],
|
|
53
|
+
scopes_supported: [
|
|
54
|
+
'openid',
|
|
55
|
+
'profile',
|
|
56
|
+
'email',
|
|
57
|
+
'offline_access'
|
|
58
|
+
],
|
|
59
|
+
jwks_uri: `${authBaseUrl}/mcp/jwks`,
|
|
60
|
+
resource_signing_alg_values_supported: [
|
|
61
|
+
'RS256',
|
|
62
|
+
'none'
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function createMcpWwwAuthenticateHeader(configService) {
|
|
67
|
+
const resourceMetadataUrl = `${resolvePublicBackendOrigin(configService)}/.well-known/oauth-protected-resource`;
|
|
68
|
+
return [
|
|
69
|
+
'Bearer',
|
|
70
|
+
`resource_metadata="${resourceMetadataUrl}"`,
|
|
71
|
+
`resource_metadata_uri="${resourceMetadataUrl}"`
|
|
72
|
+
].join(' ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//# sourceMappingURL=mcp-oauth.utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/modules/auth/mcp-oauth.utils.ts"],"sourcesContent":["import type { ConfigService } from '@nestjs/config';\n\nconst DEFAULT_BACKEND_PORT = '3001';\nconst DEFAULT_FRONTEND_PORT = '3000';\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, '');\n}\n\nfunction swapPort(origin: string, fromPort: string, toPort: string): string {\n const url = new URL(origin);\n if (url.port === fromPort) {\n url.port = toPort;\n }\n return trimTrailingSlash(url.origin);\n}\n\nexport function resolvePublicBackendOrigin(configService: ConfigService): string {\n const configuredUrl = configService.get<string>('BETTER_AUTH_URL');\n if (configuredUrl) {\n return trimTrailingSlash(new URL(configuredUrl).origin);\n }\n\n const port = configService.get<number>('app.port') || Number(DEFAULT_BACKEND_PORT);\n return `http://localhost:${port}`;\n}\n\nexport function resolvePublicAuthBaseUrl(configService: ConfigService): string {\n return `${resolvePublicBackendOrigin(configService)}/api/auth`;\n}\n\nexport function resolveFrontendOrigin(configService: ConfigService): string {\n const configuredUrl = configService.get<string>('FRONTEND_URL');\n if (configuredUrl) {\n return trimTrailingSlash(new URL(configuredUrl).origin);\n }\n\n const backendOrigin = resolvePublicBackendOrigin(configService);\n const backendUrl = new URL(backendOrigin);\n\n if (backendUrl.port === '9631') {\n return swapPort(backendOrigin, '9631', '9630');\n }\n\n if (backendUrl.port === DEFAULT_BACKEND_PORT) {\n return swapPort(backendOrigin, DEFAULT_BACKEND_PORT, DEFAULT_FRONTEND_PORT);\n }\n\n return trimTrailingSlash(backendUrl.origin);\n}\n\nexport function resolveMcpLoginPageUrl(configService: ConfigService): string {\n return `${resolveFrontendOrigin(configService)}/sign-in`;\n}\n\nexport function createMcpProtectedResourceMetadata(configService: ConfigService) {\n const backendOrigin = resolvePublicBackendOrigin(configService);\n const authBaseUrl = resolvePublicAuthBaseUrl(configService);\n\n return {\n resource: `${backendOrigin}/api/mcp`,\n authorization_servers: [backendOrigin],\n bearer_methods_supported: ['header'],\n scopes_supported: ['openid', 'profile', 'email', 'offline_access'],\n jwks_uri: `${authBaseUrl}/mcp/jwks`,\n resource_signing_alg_values_supported: ['RS256', 'none'],\n };\n}\n\nexport function createMcpWwwAuthenticateHeader(configService: ConfigService): string {\n const resourceMetadataUrl = `${resolvePublicBackendOrigin(\n configService\n )}/.well-known/oauth-protected-resource`;\n\n return [\n 'Bearer',\n `resource_metadata=\"${resourceMetadataUrl}\"`,\n `resource_metadata_uri=\"${resourceMetadataUrl}\"`,\n ].join(' ');\n}\n"],"names":["DEFAULT_BACKEND_PORT","DEFAULT_FRONTEND_PORT","trimTrailingSlash","value","replace","swapPort","origin","fromPort","toPort","url","URL","port","resolvePublicBackendOrigin","configService","configuredUrl","get","Number","resolvePublicAuthBaseUrl","resolveFrontendOrigin","backendOrigin","backendUrl","resolveMcpLoginPageUrl","createMcpProtectedResourceMetadata","authBaseUrl","resource","authorization_servers","bearer_methods_supported","scopes_supported","jwks_uri","resource_signing_alg_values_supported","createMcpWwwAuthenticateHeader","resourceMetadataUrl","join"],"mappings":"AAEA,MAAMA,uBAAuB;AAC7B,MAAMC,wBAAwB;AAE9B,SAASC,kBAAkBC,KAAa;IACtC,OAAOA,MAAMC,OAAO,CAAC,QAAQ;AAC/B;AAEA,SAASC,SAASC,MAAc,EAAEC,QAAgB,EAAEC,MAAc;IAChE,MAAMC,MAAM,IAAIC,IAAIJ;IACpB,IAAIG,IAAIE,IAAI,KAAKJ,UAAU;QACzBE,IAAIE,IAAI,GAAGH;IACb;IACA,OAAON,kBAAkBO,IAAIH,MAAM;AACrC;AAEA,OAAO,SAASM,2BAA2BC,aAA4B;IACrE,MAAMC,gBAAgBD,cAAcE,GAAG,CAAS;IAChD,IAAID,eAAe;QACjB,OAAOZ,kBAAkB,IAAIQ,IAAII,eAAeR,MAAM;IACxD;IAEA,MAAMK,OAAOE,cAAcE,GAAG,CAAS,eAAeC,OAAOhB;IAC7D,OAAO,CAAC,iBAAiB,EAAEW,MAAM;AACnC;AAEA,OAAO,SAASM,yBAAyBJ,aAA4B;IACnE,OAAO,GAAGD,2BAA2BC,eAAe,SAAS,CAAC;AAChE;AAEA,OAAO,SAASK,sBAAsBL,aAA4B;IAChE,MAAMC,gBAAgBD,cAAcE,GAAG,CAAS;IAChD,IAAID,eAAe;QACjB,OAAOZ,kBAAkB,IAAIQ,IAAII,eAAeR,MAAM;IACxD;IAEA,MAAMa,gBAAgBP,2BAA2BC;IACjD,MAAMO,aAAa,IAAIV,IAAIS;IAE3B,IAAIC,WAAWT,IAAI,KAAK,QAAQ;QAC9B,OAAON,SAASc,eAAe,QAAQ;IACzC;IAEA,IAAIC,WAAWT,IAAI,KAAKX,sBAAsB;QAC5C,OAAOK,SAASc,eAAenB,sBAAsBC;IACvD;IAEA,OAAOC,kBAAkBkB,WAAWd,MAAM;AAC5C;AAEA,OAAO,SAASe,uBAAuBR,aAA4B;IACjE,OAAO,GAAGK,sBAAsBL,eAAe,QAAQ,CAAC;AAC1D;AAEA,OAAO,SAASS,mCAAmCT,aAA4B;IAC7E,MAAMM,gBAAgBP,2BAA2BC;IACjD,MAAMU,cAAcN,yBAAyBJ;IAE7C,OAAO;QACLW,UAAU,GAAGL,cAAc,QAAQ,CAAC;QACpCM,uBAAuB;YAACN;SAAc;QACtCO,0BAA0B;YAAC;SAAS;QACpCC,kBAAkB;YAAC;YAAU;YAAW;YAAS;SAAiB;QAClEC,UAAU,GAAGL,YAAY,SAAS,CAAC;QACnCM,uCAAuC;YAAC;YAAS;SAAO;IAC1D;AACF;AAEA,OAAO,SAASC,+BAA+BjB,aAA4B;IACzE,MAAMkB,sBAAsB,GAAGnB,2BAC7BC,eACA,qCAAqC,CAAC;IAExC,OAAO;QACL;QACA,CAAC,mBAAmB,EAAEkB,oBAAoB,CAAC,CAAC;QAC5C,CAAC,uBAAuB,EAAEA,oBAAoB,CAAC,CAAC;KACjD,CAACC,IAAI,CAAC;AACT"}
|
|
@@ -253,7 +253,8 @@ export class McpService {
|
|
|
253
253
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig) : null;
|
|
254
254
|
const remoteServer = new RemoteHttpMcpServer({
|
|
255
255
|
url: config.url,
|
|
256
|
-
transport: 'http'
|
|
256
|
+
transport: 'http',
|
|
257
|
+
headers: config.headers
|
|
257
258
|
}, null, apiKeyConfig);
|
|
258
259
|
await remoteServer.initialize();
|
|
259
260
|
const tools = await remoteServer.listTools();
|
|
@@ -267,7 +268,8 @@ export class McpService {
|
|
|
267
268
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig) : null;
|
|
268
269
|
const remoteServer = new RemoteSseMcpServer({
|
|
269
270
|
url: config.url,
|
|
270
|
-
transport: 'sse'
|
|
271
|
+
transport: 'sse',
|
|
272
|
+
headers: config.headers
|
|
271
273
|
}, null, apiKeyConfig);
|
|
272
274
|
await remoteServer.initialize();
|
|
273
275
|
const tools = await remoteServer.listTools();
|
|
@@ -382,14 +384,28 @@ export class McpService {
|
|
|
382
384
|
validationDetails = 'Server ready (no API key required)';
|
|
383
385
|
}
|
|
384
386
|
// For remote_http servers, validate by connecting
|
|
387
|
+
let oauthRequired = false;
|
|
385
388
|
if (server.type === 'remote_http' && status === 'unknown') {
|
|
386
389
|
const config = this.parseConfig(server.config);
|
|
387
390
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig) : null;
|
|
391
|
+
// Get OAuth token if available, mapping Prisma types to core OAuthToken
|
|
392
|
+
const oauthToken = server.oauthToken ? {
|
|
393
|
+
id: server.oauthToken.id,
|
|
394
|
+
mcpServerId: server.oauthToken.mcpServerId,
|
|
395
|
+
accessToken: server.oauthToken.accessToken,
|
|
396
|
+
tokenType: server.oauthToken.tokenType,
|
|
397
|
+
refreshToken: server.oauthToken.refreshToken ?? undefined,
|
|
398
|
+
scope: server.oauthToken.scope ?? undefined,
|
|
399
|
+
expiresAt: server.oauthToken.expiresAt?.getTime(),
|
|
400
|
+
createdAt: server.oauthToken.createdAt.getTime(),
|
|
401
|
+
updatedAt: server.oauthToken.updatedAt.getTime()
|
|
402
|
+
} : null;
|
|
388
403
|
try {
|
|
389
404
|
const remoteServer = new RemoteHttpMcpServer({
|
|
390
405
|
url: config.url,
|
|
391
|
-
transport: 'http'
|
|
392
|
-
|
|
406
|
+
transport: 'http',
|
|
407
|
+
headers: config.headers
|
|
408
|
+
}, oauthToken, apiKeyConfig);
|
|
393
409
|
await remoteServer.initialize();
|
|
394
410
|
const tools = await remoteServer.listTools();
|
|
395
411
|
status = 'connected';
|
|
@@ -397,18 +413,36 @@ export class McpService {
|
|
|
397
413
|
} catch (error) {
|
|
398
414
|
status = 'error';
|
|
399
415
|
validationError = error instanceof Error ? error.message : 'Unknown error';
|
|
400
|
-
|
|
416
|
+
if (validationError.includes('OAUTH_REQUIRED')) {
|
|
417
|
+
oauthRequired = true;
|
|
418
|
+
validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
|
|
419
|
+
} else {
|
|
420
|
+
validationDetails = `Connection failed: ${validationError}`;
|
|
421
|
+
}
|
|
401
422
|
}
|
|
402
423
|
}
|
|
403
424
|
// For remote_sse servers, validate by connecting
|
|
404
425
|
if (server.type === 'remote_sse' && status === 'unknown') {
|
|
405
426
|
const config = this.parseConfig(server.config);
|
|
406
427
|
const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig) : null;
|
|
428
|
+
// Get OAuth token if available, mapping Prisma types to core OAuthToken
|
|
429
|
+
const oauthToken = server.oauthToken ? {
|
|
430
|
+
id: server.oauthToken.id,
|
|
431
|
+
mcpServerId: server.oauthToken.mcpServerId,
|
|
432
|
+
accessToken: server.oauthToken.accessToken,
|
|
433
|
+
tokenType: server.oauthToken.tokenType,
|
|
434
|
+
refreshToken: server.oauthToken.refreshToken ?? undefined,
|
|
435
|
+
scope: server.oauthToken.scope ?? undefined,
|
|
436
|
+
expiresAt: server.oauthToken.expiresAt?.getTime(),
|
|
437
|
+
createdAt: server.oauthToken.createdAt.getTime(),
|
|
438
|
+
updatedAt: server.oauthToken.updatedAt.getTime()
|
|
439
|
+
} : null;
|
|
407
440
|
try {
|
|
408
441
|
const remoteServer = new RemoteSseMcpServer({
|
|
409
442
|
url: config.url,
|
|
410
|
-
transport: 'sse'
|
|
411
|
-
|
|
443
|
+
transport: 'sse',
|
|
444
|
+
headers: config.headers
|
|
445
|
+
}, oauthToken, apiKeyConfig);
|
|
412
446
|
await remoteServer.initialize();
|
|
413
447
|
const tools = await remoteServer.listTools();
|
|
414
448
|
status = 'connected';
|
|
@@ -416,7 +450,12 @@ export class McpService {
|
|
|
416
450
|
} catch (error) {
|
|
417
451
|
status = 'error';
|
|
418
452
|
validationError = error instanceof Error ? error.message : 'Unknown error';
|
|
419
|
-
|
|
453
|
+
if (validationError.includes('OAUTH_REQUIRED')) {
|
|
454
|
+
oauthRequired = true;
|
|
455
|
+
validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
|
|
456
|
+
} else {
|
|
457
|
+
validationDetails = `Connection failed: ${validationError}`;
|
|
458
|
+
}
|
|
420
459
|
}
|
|
421
460
|
}
|
|
422
461
|
// For external (NPX/stdio) servers, validate by spawning and checking
|
|
@@ -451,6 +490,7 @@ export class McpService {
|
|
|
451
490
|
hasOAuth,
|
|
452
491
|
requiresApiKey,
|
|
453
492
|
requiresOAuth,
|
|
493
|
+
oauthRequired,
|
|
454
494
|
isReady,
|
|
455
495
|
status,
|
|
456
496
|
error: validationError,
|