diff --git a/libraries/nestjs-libraries/src/chat/oauth-middleware.ts b/libraries/nestjs-libraries/src/chat/oauth-middleware.ts new file mode 100644 index 00000000..7262f037 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/oauth-middleware.ts @@ -0,0 +1,251 @@ +/** + * OAuth Middleware for MCP Server + * + * Implements OAuth 2.0 Protected Resource support per RFC 9728 for MCP servers. + * Based on Mastra's implementation at commit 27c37ca. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization + * @see https://www.rfc-editor.org/rfc/rfc9728.html + */ + +import type * as http from 'node:http'; + +import type { MCPServerOAuthConfig, TokenValidationResult } from './oauth-types'; +import { + generateProtectedResourceMetadata, + generateWWWAuthenticateHeader, + extractBearerToken, +} from './oauth-types'; + +interface OAuthMiddlewareLogger { + debug?: (message: string, ...args: unknown[]) => void; +} + +export interface OAuthMiddlewareOptions { + oauth: MCPServerOAuthConfig; + mcpPath?: string; + logger?: OAuthMiddlewareLogger; +} + +export interface OAuthMiddlewareResult { + proceed: boolean; + handled: boolean; + tokenValidation?: TokenValidationResult; +} + +export function createOAuthMiddleware(options: OAuthMiddlewareOptions) { + const { oauth, mcpPath = '/mcp', logger } = options; + + const protectedResourceMetadata = generateProtectedResourceMetadata(oauth); + const wellKnownPath = '/.well-known/oauth-protected-resource'; + const resourceMetadataUrl = new URL(wellKnownPath, oauth.resource).toString(); + + return async function oauthMiddleware( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + ): Promise { + logger?.debug?.(`OAuth middleware: ${req.method} ${url.pathname}`); + + // Handle Protected Resource Metadata endpoint (RFC 9728) + if (url.pathname === wellKnownPath && req.method === 'GET') { + logger?.debug?.('OAuth middleware: Serving Protected Resource Metadata'); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=3600', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(protectedResourceMetadata)); + return { proceed: false, handled: true }; + } + + // Handle CORS preflight for metadata endpoint + if (url.pathname === wellKnownPath && req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + }); + res.end(); + return { proceed: false, handled: true }; + } + + // Only protect the MCP endpoint + if (!url.pathname.startsWith(mcpPath)) { + return { proceed: true, handled: false }; + } + + // Extract and validate bearer token + const authHeader = req.headers['authorization']; + const token = extractBearerToken(authHeader as string | undefined); + + if (!token) { + logger?.debug?.('OAuth middleware: No bearer token provided'); + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': generateWWWAuthenticateHeader({ resourceMetadataUrl }), + }); + res.end( + JSON.stringify({ + error: 'unauthorized', + error_description: 'Bearer token required', + }), + ); + return { proceed: false, handled: true }; + } + + // Validate the token + if (oauth.validateToken) { + logger?.debug?.('OAuth middleware: Validating token'); + const validationResult = await oauth.validateToken(token, oauth.resource); + + if (!validationResult.valid) { + logger?.debug?.(`OAuth middleware: Token validation failed: ${validationResult.error}`); + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': generateWWWAuthenticateHeader({ + resourceMetadataUrl, + additionalParams: { + error: validationResult.error || 'invalid_token', + ...(validationResult.errorDescription && { + error_description: validationResult.errorDescription, + }), + }, + }), + }); + res.end( + JSON.stringify({ + error: validationResult.error || 'invalid_token', + error_description: validationResult.errorDescription || 'Token validation failed', + }), + ); + return { proceed: false, handled: true, tokenValidation: validationResult }; + } + + logger?.debug?.('OAuth middleware: Token validated successfully'); + return { proceed: true, handled: false, tokenValidation: validationResult }; + } + + // If no validateToken function provided, accept the token + logger?.debug?.('OAuth middleware: No token validation configured, accepting token'); + return { + proceed: true, + handled: false, + tokenValidation: { valid: true }, + }; + }; +} + +export function createStaticTokenValidator(validTokens: string[]): MCPServerOAuthConfig['validateToken'] { + const tokenSet = new Set(validTokens); + return async (token: string): Promise => { + if (tokenSet.has(token)) { + return { valid: true, scopes: ['mcp:read', 'mcp:write'] }; + } + return { + valid: false, + error: 'invalid_token', + errorDescription: 'Token not recognized', + }; + }; +} + +interface IntrospectionResponse { + active: boolean; + scope?: string; + client_id?: string; + username?: string; + token_type?: string; + exp?: number; + iat?: number; + nbf?: number; + sub?: string; + aud?: string | string[]; + iss?: string; + jti?: string; + [key: string]: unknown; +} + +export function createIntrospectionValidator( + introspectionEndpoint: string, + clientCredentials?: { clientId: string; clientSecret: string }, +): MCPServerOAuthConfig['validateToken'] { + return async (token: string, resource: string): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (clientCredentials) { + if (clientCredentials.clientId.includes(':')) { + return { + valid: false, + error: 'invalid_request', + errorDescription: 'clientId cannot contain a colon character per RFC 7617', + }; + } + const credentials = Buffer.from(`${clientCredentials.clientId}:${clientCredentials.clientSecret}`).toString( + 'base64', + ); + headers['Authorization'] = `Basic ${credentials}`; + } + + const response = await fetch(introspectionEndpoint, { + method: 'POST', + headers, + body: new URLSearchParams({ + token, + token_type_hint: 'access_token', + }), + }); + + if (!response.ok) { + return { + valid: false, + error: 'server_error', + errorDescription: `Introspection failed: ${response.status}`, + }; + } + + const data = (await response.json()) as IntrospectionResponse; + + if (!data.active) { + return { + valid: false, + error: 'invalid_token', + errorDescription: 'Token is not active', + }; + } + + if (data.aud) { + const audiences = Array.isArray(data.aud) ? data.aud : [data.aud]; + if (!audiences.includes(resource)) { + return { + valid: false, + error: 'invalid_token', + errorDescription: 'Token audience does not match this resource', + }; + } + } + + return { + valid: true, + scopes: + data.scope + ?.trim() + .split(' ') + .filter(s => s !== '') || [], + subject: data.sub, + expiresAt: data.exp, + claims: data as Record, + }; + } catch (error) { + return { + valid: false, + error: 'server_error', + errorDescription: error instanceof Error ? error.message : 'Introspection failed', + }; + } + }; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/chat/oauth-types.ts b/libraries/nestjs-libraries/src/chat/oauth-types.ts new file mode 100644 index 00000000..67b4ec44 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/oauth-types.ts @@ -0,0 +1,104 @@ +/** + * OAuth Types for MCP Authentication + * + * Standalone types and helpers for OAuth-protected MCP servers. + * Based on Mastra's implementation at commit 27c37ca. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization + * @see https://www.rfc-editor.org/rfc/rfc9728.html + */ + +import type * as http from 'node:http'; + +/** + * Configuration for OAuth-protected MCP server. + */ +export interface MCPServerOAuthConfig { + resource: string; + authorizationServers: string[]; + scopesSupported?: string[]; + resourceName?: string; + resourceDocumentation?: string; + validateToken?: (token: string, resource: string) => Promise; +} + +/** + * Result of token validation. + */ +export interface TokenValidationResult { + valid: boolean; + error?: string; + errorDescription?: string; + scopes?: string[]; + subject?: string; + expiresAt?: number; + claims?: Record; +} + +/** + * Options for OAuth-related HTTP responses. + */ +export interface OAuthResponseOptions { + resourceMetadataUrl?: string; + additionalParams?: Record; +} + +/** + * Protected Resource Metadata per RFC 9728. + */ +export interface OAuthProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported?: string[]; + bearer_methods_supported?: string[]; + resource_name?: string; + resource_documentation?: string; +} + +function escapeHeaderValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +export function generateWWWAuthenticateHeader(options: OAuthResponseOptions = {}): string { + const params: string[] = []; + + if (options.resourceMetadataUrl) { + params.push(`resource_metadata="${escapeHeaderValue(options.resourceMetadataUrl)}"`); + } + + if (options.additionalParams) { + for (const [key, value] of Object.entries(options.additionalParams)) { + params.push(`${key}="${escapeHeaderValue(value)}"`); + } + } + + if (params.length === 0) { + return 'Bearer'; + } + + return `Bearer ${params.join(', ')}`; +} + +export function generateProtectedResourceMetadata(config: MCPServerOAuthConfig): OAuthProtectedResourceMetadata { + return { + resource: config.resource, + authorization_servers: config.authorizationServers, + scopes_supported: config.scopesSupported ?? ['mcp:read', 'mcp:write'], + bearer_methods_supported: ['header'], + ...(config.resourceName && { resource_name: config.resourceName }), + ...(config.resourceDocumentation && { + resource_documentation: config.resourceDocumentation, + }), + }; +} + +export function extractBearerToken(authHeader: string | null | undefined): string | undefined { + if (!authHeader) return undefined; + + const prefix = 'bearer '; + if (authHeader.length <= prefix.length) return undefined; + if (authHeader.slice(0, prefix.length).toLowerCase() !== prefix) return undefined; + + const token = authHeader.slice(prefix.length).trim(); + return token || undefined; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts index cc2dd5d7..0b29419d 100644 --- a/libraries/nestjs-libraries/src/chat/start.mcp.ts +++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'crypto'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service'; import { runWithContext } from './async.storage'; +import { createOAuthMiddleware } from './oauth-middleware'; export const startMcp = async (app: INestApplication) => { const mastraService = app.get(MastraService, { strict: false }); const organizationService = app.get(OrganizationService, { strict: false }); @@ -33,6 +34,82 @@ export const startMcp = async (app: INestApplication) => { const server = new MCPServer(serverConfig); + const oauthMiddleware = createOAuthMiddleware({ + oauth: { + resource: new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL!).toString(), + authorizationServers: [process.env.NEXT_PUBLIC_BACKEND_URL!], + validateToken: async (token: string) => { + const org = await resolveAuth(token); + if (!org) { + return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' }; + } + return { valid: true, subject: token }; + }, + }, + mcpPath: '/mcp-oauth', + }); + + app.use('/.well-known/oauth-protected-resource', async (req: Request, res: Response) => { + const url = new URL('/.well-known/oauth-protected-resource', process.env.NEXT_PUBLIC_BACKEND_URL); + await oauthMiddleware(req, res, url); + }); + + app.use('/.well-known/oauth-authorization-server', async (req: Request, res: Response) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.writeHead(204); + res.end(); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'max-age=3600'); + res.json({ + issuer: process.env.NEXT_PUBLIC_BACKEND_URL, + authorization_endpoint: `${process.env.FRONTEND_URL}/oauth/authorize`, + token_endpoint: `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth/token`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp:read', 'mcp:write'], + }); + }); + + app.use('/mcp-oauth', async (req: Request, res: Response, next: () => void) => { + // Skip if this is the /mcp/:id route + if (req.path !== '/' && req.path !== '') { + next(); + return; + } + + const url = new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL); + + const result = await oauthMiddleware(req, res, url); + if (!result.proceed) return; + + const token = result.tokenValidation?.subject; + const auth = await resolveAuth(token!); + if (!auth) { + res.status(401).json({ error: 'invalid_token', error_description: 'Could not resolve organization' }); + return; + } + + await runWithContext({ requestId: token!, auth }, async () => { + await server.startHTTP({ + url: url, + httpPath: url.pathname, + options: { + sessionIdGenerator: () => { + return randomUUID(); + }, + }, + req, + res, + }); + }); + }); + app.use('/mcp', async (req: Request, res: Response, next: () => void) => { // Skip if this is the /mcp/:id route if (req.path !== '/' && req.path !== '') {