UNPKG

mcpresso

Version:

TypeScript package for Model Context Protocol (MCP) utilities and tools

386 lines (385 loc) • 19.1 kB
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { URL } from 'url'; /** * Creates an Express middleware for validating MCP access tokens. * This middleware implements the MCP authorization specification: * https://modelcontextprotocol.io/specification/draft/basic/authorization * * @param authConfig The MCP authentication configuration. * @param serverUrl The canonical URL of this MCP server, used as the required 'audience'. * @returns An Express middleware function. */ export function createMCPAuthMiddleware(authConfig, serverUrl) { // console.log("createMCPAuthMiddleware", authConfig, serverUrl); // Validate that we have either issuer, oauth server, or bearer token if (!authConfig.issuer && !authConfig.oauth && !authConfig.bearerToken) { throw new Error("MCPAuthConfig must provide either 'issuer' (for external OAuth), 'oauth' (for integrated OAuth), or 'bearerToken' (for bearer token auth)"); } // Use provided serverUrl or fallback to config const canonicalServerUrl = serverUrl || authConfig.serverUrl; // Check if this is a bearer token authentication if (authConfig.bearerToken) { const { headerName = "Authorization", token, userProfile } = authConfig.bearerToken; return async (req, res, next) => { console.log("\n" + "=".repeat(60)); console.log("šŸ” BEARER TOKEN AUTH MIDDLEWARE DEBUG"); console.log("=".repeat(60)); console.log("šŸ“ Request Details:"); console.log(" URL:", req.url); console.log(" Method:", req.method); console.log(" IP:", req.ip || req.connection.remoteAddress); console.log(" User-Agent:", req.headers['user-agent']); console.log("\nšŸ”‘ Authentication Headers:"); console.log(` ${headerName}:`, req.headers[headerName.toLowerCase()] || "āŒ MISSING"); console.log(" MCP-Session-ID:", req.headers['mcp-session-id'] || "āŒ MISSING"); console.log("\nšŸ“Ø All Request Headers:"); Object.entries(req.headers).forEach(([key, value]) => { console.log(` ${key}: ${value}`); }); console.log("\nšŸ“¦ Request Body (if any):"); if (req.body && Object.keys(req.body).length > 0) { console.log(" ", JSON.stringify(req.body, null, 2)); } else { console.log(" āŒ No body"); } console.log("=".repeat(60)); const authHeader = req.headers[headerName.toLowerCase()]; // Check for Bearer token if (!authHeader || !(typeof authHeader === 'string' ? authHeader.toLowerCase().startsWith('bearer ') : authHeader[0].toLowerCase().startsWith('bearer '))) { console.log("\nāŒ AUTHENTICATION FAILED"); console.log("🚫 Reason: Missing or invalid Authorization header"); console.log(`šŸ” Expected: '${headerName}: Bearer <token>'`); console.log("šŸ” Received:", authHeader || "undefined"); console.log("šŸ“¤ Sending 401 Unauthorized response"); console.log("=".repeat(60) + "\n"); return res .status(401) .header('WWW-Authenticate', `Bearer`) .json({ error: 'missing_or_invalid_authorization_header', error_description: 'Authorization header with Bearer token is required', details: `${headerName} header must start with "Bearer "` }); } const receivedToken = typeof authHeader === 'string' ? authHeader.substring(7) : authHeader[0].substring(7); // Validate the bearer token if (receivedToken !== token) { console.log("\nāŒ AUTHENTICATION FAILED"); console.log("🚫 Reason: Invalid bearer token"); console.log("šŸ“¤ Sending 401 Unauthorized response"); console.log("=".repeat(60) + "\n"); return res .status(401) .header('WWW-Authenticate', `Bearer error="invalid_token"`) .json({ error: 'invalid_token', error_description: 'Invalid bearer token' }); } // Create user profile const profile = userProfile || { id: "bearer-user", username: "api-client", email: "api@example.com", scopes: ["read", "write"] }; // Attach the user profile to the request req.auth = profile; console.log("\nāœ… BEARER TOKEN AUTHENTICATION SUCCESSFUL"); console.log("šŸ‘¤ User Profile:"); console.log(" ID:", profile.id); console.log(" Username:", profile.username || "N/A"); console.log(" Email:", profile.email || "N/A"); console.log(" Scopes:", profile.scopes || "N/A"); console.log("šŸ“¤ Proceeding to next middleware/handler"); console.log("=".repeat(60) + "\n"); next(); }; } // Get issuer from config or OAuth server const issuer = authConfig.issuer || authConfig.oauth?.config?.issuer; if (!issuer) { throw new Error("Unable to determine issuer URL from auth config"); } // Decide verification method: JWKS (default) or shared secret (HS256) const jwtSecret = authConfig.jwtSecret || authConfig.oauth?.config?.jwtSecret; const useSharedSecret = !!jwtSecret; let JWKS; if (useSharedSecret) { JWKS = new TextEncoder().encode(jwtSecret); } else { const jwksEndpoint = authConfig.jwksEndpoint || `/.well-known/jwks.json`; const jwksUrl = new URL(jwksEndpoint, issuer); JWKS = createRemoteJWKSet(jwksUrl); } // Construct the metadata URL for WWW-Authenticate headers let metadataUrl; try { if (authConfig.metadataEndpoint) { // Use custom metadata endpoint path if (canonicalServerUrl) { metadataUrl = new URL(authConfig.metadataEndpoint, canonicalServerUrl).href; } else { metadataUrl = authConfig.metadataEndpoint; } } else if (!canonicalServerUrl) { metadataUrl = "/.well-known/oauth-protected-resource-metadata"; } else { metadataUrl = new URL('/.well-known/oauth-protected-resource-metadata', canonicalServerUrl).href; } } catch (error) { metadataUrl = "/.well-known/oauth-protected-resource-metadata"; } // Get default values for configuration const config = { requireResourceIndicator: authConfig.requireResourceIndicator ?? true, validateAudience: authConfig.validateAudience ?? true, errorHandling: { includeDetails: authConfig.errorHandling?.includeDetails ?? true, messages: { missingToken: authConfig.errorHandling?.messages?.missingToken ?? 'Authorization header with Bearer token is required', invalidToken: authConfig.errorHandling?.messages?.invalidToken ?? 'Invalid token', expiredToken: authConfig.errorHandling?.messages?.expiredToken ?? 'Token has expired', audienceMismatch: authConfig.errorHandling?.messages?.audienceMismatch ?? 'Token audience mismatch', signatureFailure: authConfig.errorHandling?.messages?.signatureFailure ?? 'Token signature verification failed', } }, logging: { logSuccess: authConfig.logging?.logSuccess ?? true, logFailures: authConfig.logging?.logFailures ?? true, logValidation: authConfig.logging?.logValidation ?? true, } }; return async (req, res, next) => { console.log("\n" + "=".repeat(60)); console.log("šŸ” AUTH MIDDLEWARE DEBUG"); console.log("=".repeat(60)); console.log("šŸ“ Request Details:"); console.log(" URL:", req.url); console.log(" Method:", req.method); console.log(" IP:", req.ip || req.connection.remoteAddress); console.log(" User-Agent:", req.headers['user-agent']); console.log("\nšŸ”‘ Authentication Headers:"); console.log(" Authorization:", req.headers.authorization || "āŒ MISSING"); console.log(" MCP-Session-ID:", req.headers['mcp-session-id'] || "āŒ MISSING"); console.log("\nšŸ“Ø All Request Headers:"); Object.entries(req.headers).forEach(([key, value]) => { console.log(` ${key}: ${value}`); }); console.log("\nšŸ“¦ Request Body (if any):"); if (req.body && Object.keys(req.body).length > 0) { console.log(" ", JSON.stringify(req.body, null, 2)); } else { console.log(" āŒ No body"); } console.log("=".repeat(60)); const authHeader = req.headers.authorization; // Check for Bearer token if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) { console.log("\nāŒ AUTHENTICATION FAILED"); console.log("🚫 Reason: Missing or invalid Authorization header"); console.log("šŸ” Expected: 'Authorization: Bearer <token>'"); console.log("šŸ” Received:", authHeader || "undefined"); console.log("šŸ“¤ Sending 401 Unauthorized response"); console.log("=".repeat(60) + "\n"); if (config.logging.logFailures) { console.log(`[AUTH] Missing or invalid authorization header from ${req.ip}`); } return res .status(401) .header('WWW-Authenticate', `Bearer, resource_metadata_uri="${metadataUrl}"`) .json({ error: 'missing_or_invalid_authorization_header', error_description: config.errorHandling.messages.missingToken, ...(config.errorHandling.includeDetails && { details: 'Authorization header must start with "Bearer "' }) }); } const token = authHeader.substring(7); try { // Prepare JWT verification options const jwtVerifyOptions = { issuer: authConfig.jwtOptions?.issuer || issuer, audience: authConfig.jwtOptions?.audience || (canonicalServerUrl ? [canonicalServerUrl.replace(/\/+$/, ''), canonicalServerUrl.replace(/\/+$/, '') + '/'] : undefined), }; // Add clock tolerance if specified if (authConfig.jwtOptions?.clockTolerance) { jwtVerifyOptions.clockTolerance = authConfig.jwtOptions.clockTolerance; } // Add max token age if specified if (authConfig.jwtOptions?.maxTokenAge) { jwtVerifyOptions.maxTokenAge = authConfig.jwtOptions.maxTokenAge; } if (config.logging.logValidation) { console.log(`[AUTH] Validating token with options:`, { issuer: jwtVerifyOptions.issuer, audience: jwtVerifyOptions.audience, clockTolerance: jwtVerifyOptions.clockTolerance, maxTokenAge: jwtVerifyOptions.maxTokenAge }); } // Get JWT algorithm const jwtAlgorithm = authConfig.jwtAlgorithm || authConfig.oauth?.config?.jwtAlgorithm || 'HS256'; // Validate JWT token const { payload } = useSharedSecret ? await jwtVerify(token, JWKS, { algorithms: [jwtAlgorithm], issuer: jwtVerifyOptions.issuer, }) : await jwtVerify(token, JWKS, jwtVerifyOptions); if (config.logging.logValidation) { console.log('[AUTH] Token payload aud:', payload.aud); } // MCP-specific: Validate audience if required if (config.validateAudience && canonicalServerUrl) { const aud = payload.aud ? (Array.isArray(payload.aud) ? payload.aud.map((a) => (typeof a === 'string' ? a.replace(/\/+$/, '') : a)) : (typeof payload.aud === 'string' ? [payload.aud.replace(/\/+$/, '')] : [])) : undefined; const audiences = aud; const canonicalNormalized = canonicalServerUrl.replace(/\/+$/, ''); if (config.logging.logValidation) { console.log('[AUTH] Normalized audiences:', audiences, ' Expected:', canonicalNormalized); } if (!audiences.includes(canonicalNormalized)) { if (config.logging.logFailures) { console.log(`[AUTH] Token audience mismatch from ${req.ip}. Expected: ${canonicalServerUrl}, Got: ${audiences.join(', ')}`); } return res .status(401) .header('WWW-Authenticate', `Bearer error="invalid_token", error_description="${config.errorHandling.messages.audienceMismatch}", resource_metadata_uri="${metadataUrl}"`) .json({ error: 'invalid_token', error_description: config.errorHandling.messages.audienceMismatch, ...(config.errorHandling.includeDetails && { details: `Expected audience: ${canonicalServerUrl}, Got: ${audiences.join(', ')}` }) }); } } // Log successful authentication if enabled if (config.logging.logSuccess) { console.log(`[AUTH] Successful authentication for user ${payload.sub} from ${req.ip}`); } // Fetch full user profile if userLookup function is provided let userProfile = payload; if (authConfig.userLookup) { try { const fetchedProfile = await authConfig.userLookup(payload); if (fetchedProfile) { userProfile = fetchedProfile; } else { console.warn(`[AUTH] User lookup returned null for user ${payload.sub}`); } } catch (error) { console.error(`[AUTH] Error fetching user profile for ${payload.sub}:`, error); // Continue with JWT payload as fallback } } // Attach the user profile (or JWT payload as fallback) to the request for use in handlers req.auth = userProfile; console.log("\nāœ… AUTHENTICATION SUCCESSFUL"); console.log("šŸ‘¤ User Profile:"); console.log(" ID:", userProfile.id || userProfile.sub); console.log(" Username:", userProfile.username || "N/A"); console.log(" Email:", userProfile.email || "N/A"); console.log(" Scopes:", userProfile.scopes || userProfile.scope || "N/A"); console.log("šŸ“¤ Proceeding to next middleware/handler"); console.log("=".repeat(60) + "\n"); next(); } catch (error) { let message = config.errorHandling.messages.invalidToken; let errorCode = 'invalid_token'; if (error.code === 'ERR_JWT_EXPIRED') { message = config.errorHandling.messages.expiredToken; errorCode = 'invalid_token'; } else if (error.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { message = config.errorHandling.messages.signatureFailure; errorCode = 'invalid_token'; } else if (error.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') { message = `Token claim validation failed: ${error.claim} ${error.reason}`; errorCode = 'invalid_token'; } else if (error.code === 'ERR_JWT_MALFORMED') { message = 'Malformed token'; errorCode = 'invalid_token'; } if (config.logging.logFailures) { console.log(`[AUTH] Authentication failed from ${req.ip}: ${message}`, { errorCode: error.code, claim: error.claim, reason: error.reason }); } return res .status(401) .header('WWW-Authenticate', `Bearer error="${errorCode}", error_description="${message}", resource_metadata_uri="${metadataUrl}"`) .json({ error: errorCode, error_description: message, ...(config.errorHandling.includeDetails && { details: error.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED' ? `Claim: ${error.claim}, Reason: ${error.reason}` : `Error code: ${error.code}` }) }); } }; } /** * Creates the MCP Protected Resource Metadata endpoint. * This implements RFC 9728 as required by the MCP specification. * * @param authConfig The MCP authentication configuration. * @param serverUrl The canonical URL of this MCP server. * @returns An Express route handler for the metadata endpoint. */ export function createMCPProtectedResourceMetadataHandler(authConfig, serverUrl) { const canonicalServerUrl = serverUrl || authConfig.serverUrl; return (req, res) => { if (!canonicalServerUrl) { return res.status(500).json({ error: 'server_configuration_error', error_description: 'Server URL not configured' }); } // Handle bearer token authentication if (authConfig.bearerToken) { const metadata = { resource: canonicalServerUrl, authorization_servers: [], scopes_supported: ['read', 'write', 'admin'], bearer_methods_supported: ['Authorization header'], auth_type: 'bearer_token' }; return res.json(metadata); } // Handle OAuth authentication const metadata = { resource: canonicalServerUrl, authorization_servers: [authConfig.issuer], scopes_supported: ['read', 'write', 'admin'], bearer_methods_supported: ['Authorization header'] }; res.json(metadata); }; } /** * @deprecated Use createMCPAuthMiddleware instead */ export function createAuthMiddleware(authConfig, serverUrl) { console.warn('createAuthMiddleware is deprecated. Use createMCPAuthMiddleware instead.'); const mcpConfig = { issuer: authConfig.issuer, serverUrl, requireResourceIndicator: true, validateAudience: true }; return createMCPAuthMiddleware(mcpConfig, serverUrl); }