UNPKG

mcpresso-oauth-server

Version:

Production-ready OAuth 2.1 server implementation for Model Context Protocol (MCP) with PKCE support

660 lines (571 loc) • 23.4 kB
import express from 'express' import cors, { CorsOptions } from 'cors' import compression from 'compression' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import { MCPOAuthServer } from './oauth-server.js' import type { MCPOAuthConfig, HTTPServerConfig } from './types.js' export function registerOAuthEndpoints(app: express.Application, oauthServer: MCPOAuthServer, basePath = "") { const renderSuccessPage = (redirectUrl: string) => `<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Authorization successful</title> <meta http-equiv="refresh" content="1;url=${redirectUrl}"> <style> :root { --primary:#2563eb; --bg:#f9fafb; --card-bg:#ffffff; --border:#e5e7eb; --radius:10px; --font:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } body { margin:0; background:var(--bg); display:flex; align-items:center; justify-content:center; height:100vh; font-family:var(--font); } .card { width:100%; max-width:480px; background:var(--card-bg); padding:28px 32px; border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 10px 30px rgba(0,0,0,0.06); text-align:center; } h1 { margin:0 0 8px; font-size:22px; } p { color:#4b5563; font-size:14px; margin:0 0 14px; } a { color:var(--primary); text-decoration:none; } </style> </head> <body> <div class="card"> <h1>Authorization successful</h1> <p>You can close this window. Redirecting you back to your application…</p> <p><a href="${redirectUrl}">Continue</a></p> </div> <script>setTimeout(function(){ location.replace(${JSON.stringify(redirectUrl)}); }, 900);</script> </body> </html>` // Authorization endpoint (GET - show login page or redirect) app.get(`${basePath}/authorize`, async (req, res) => { try { const q = req.query as Record<string, any> const first = (v: any) => Array.isArray(v) ? v[0] : v const params = { response_type: first(q.response_type) as 'code', client_id: first(q.client_id) as string, redirect_uri: first(q.redirect_uri) as string, scope: first(q.scope) as string, state: first(q.state) as string, resource: first(q.resource) as string, code_challenge: first(q.code_challenge) as string, code_challenge_method: first(q.code_challenge_method) as 'S256' | 'plain' } const requestContext = { ipAddress: (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || (req.connection as any).remoteAddress || '0.0.0.0', userAgent: req.headers['user-agent'] } const result = await oauthServer.handleAuthorizationRequest(params, undefined, (req as any).session, requestContext) if ('error' in result) { return res.status(400).json(result) } if ('loginPage' in result) { return res.type('html').send(result.loginPage) } if ('redirectUrl' in result) { res.type('html').send(renderSuccessPage(result.redirectUrl)) } } catch (error) { console.error('Authorization error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Authorization endpoint (POST - handle login form submission) app.post(`${basePath}/authorize`, async (req, res) => { try { const b = req.body as Record<string, any> const first = (v: any) => Array.isArray(v) ? v[0] : v const params = { response_type: first(b.response_type) as 'code', client_id: first(b.client_id) as string, redirect_uri: first(b.redirect_uri) as string, scope: first(b.scope) as string, state: first(b.state) as string, resource: first(b.resource) as string, code_challenge: first(b.code_challenge) as string, code_challenge_method: first(b.code_challenge_method) as 'S256' | 'plain' } const credentials = first(b.username) && first(b.password) ? { username: first(b.username) as string, password: first(b.password) as string } : undefined const requestContext = { ipAddress: (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || (req.connection as any).remoteAddress || '0.0.0.0', userAgent: req.headers['user-agent'] } const result = await oauthServer.handleAuthorizationRequest(params, credentials, (req as any).session, requestContext) if ('error' in result) { return res.status(400).json(result) } if ('loginPage' in result) { return res.type('html').send(result.loginPage) } if ('redirectUrl' in result) { res.type('html').send(renderSuccessPage(result.redirectUrl)) } } catch (error) { console.error('Authorization error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token endpoint app.post(`${basePath}/token`, async (req, res) => { try { const params = { grant_type: req.body.grant_type as 'authorization_code' | 'refresh_token' | 'client_credentials', client_id: req.body.client_id as string, client_secret: req.body.client_secret as string, code: req.body.code as string, redirect_uri: req.body.redirect_uri as string, refresh_token: req.body.refresh_token as string, scope: req.body.scope as string, resource: req.body.resource as string, code_verifier: req.body.code_verifier as string } const result = await oauthServer.handleTokenRequest(params) if ('error' in result) { return res.status(400).json(result) } res.json(result) } catch (error) { console.error('Token error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token introspection endpoint app.post(`${basePath}/introspect`, async (req, res) => { try { const { token } = req.body if (!token) { return res.status(400).json({ error: 'invalid_request', error_description: 'token parameter is required' }) } const result = await oauthServer.introspectToken(token) res.json(result) } catch (error) { console.error('Introspection error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token revocation endpoint app.post(`${basePath}/revoke`, async (req, res) => { try { const { token, client_id } = req.body if (!token || !client_id) { return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id parameters are required' }) } const result = await oauthServer.revokeToken(token, client_id) res.json(result) } catch (error) { console.error('Revocation error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // User info endpoint app.get(`${basePath}/userinfo`, async (req, res) => { try { const authHeader = req.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'invalid_token', error_description: 'Bearer token required' }) } const token = authHeader.substring(7) const result = await oauthServer.getUserInfo(token) if ('error' in result) { return res.status(401).json(result) } res.json(result) } catch (error) { console.error('User info error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Dynamic client registration endpoint (RFC 7591) app.post(`${basePath}/register`, async (req, res) => { try { const result = await oauthServer.registerClient(req.body) if ('error' in result) { return res.status(400).json(result) } res.status(201).json(result) } catch (error) { console.error('Client registration error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // ===== DISCOVERY ENDPOINTS ===== // OAuth Authorization Server Metadata (RFC 8414) app.get(`${basePath}/.well-known/oauth-authorization-server`, (req, res) => { const metadata = oauthServer.getAuthorizationServerMetadata() res.json(metadata) }) // JWKS endpoint app.get(`${basePath}/.well-known/jwks.json`, (req, res) => { // For now, return empty JWKS since we're using HMAC // In production, you'd use RSA keys res.json({ keys: [] }) }) // MCP Protected Resource Metadata (RFC 9728) - FIXED ENDPOINT app.get(`${basePath}/.well-known/oauth-protected-resource`, (req, res) => { const metadata = oauthServer.getProtectedResourceMetadata() res.json(metadata) }) // ===== ADMIN ENDPOINTS ===== // Health check app.get(`${basePath}/health`, (req, res) => { res.json({ status: 'ok', service: 'mcp-oauth-server', version: '1.0.0', timestamp: new Date().toISOString() }) }) // List clients app.get(`${basePath}/admin/clients`, async (req, res) => { try { const clients = await oauthServer.listClients() res.json(clients) } catch (error) { console.error('List clients error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // List users app.get(`${basePath}/admin/users`, async (req, res) => { try { const users = await oauthServer.listUsers() res.json(users) } catch (error) { console.error('List users error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Server stats app.get(`${basePath}/admin/stats`, async (req, res) => { try { const stats = await oauthServer.getStats() res.json(stats) } catch (error) { console.error('Stats error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // 404 handler // app.use(`${basePath}/*`, (req, res) => { // res.status(404).json({ // error: 'not_found', // error_description: 'Endpoint not found', // path: req.originalUrl // }) // }) } export class MCPOAuthHttpServer { private app: express.Application private oauthServer: MCPOAuthServer private config: MCPOAuthConfig constructor(oauthServer: MCPOAuthServer, config: MCPOAuthConfig) { this.oauthServer = oauthServer this.config = config this.app = express() this.setupMiddleware() this.setupRoutes() this.setupErrorHandling() } private setupMiddleware(): void { const httpConfig = this.config.http || {} // Trust proxy (important for production behind load balancers) if (httpConfig.trustProxy !== undefined) { this.app.set('trust proxy', httpConfig.trustProxy) } // Security headers if (httpConfig.enableHelmet !== false) { this.app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], }, }, })) } // Compression if (httpConfig.enableCompression !== false) { this.app.use(compression()) } // Rate limiting if (httpConfig.enableRateLimit !== false) { const rateLimitConfig = httpConfig.rateLimitConfig || {} const limiter = rateLimit({ windowMs: rateLimitConfig.windowMs || 15 * 60 * 1000, // 15 minutes max: rateLimitConfig.max || 100, // limit each IP to 100 requests per windowMs message: rateLimitConfig.message || 'Too many requests from this IP, please try again later.', standardHeaders: rateLimitConfig.standardHeaders !== false, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: rateLimitConfig.legacyHeaders !== false, // Disable the `X-RateLimit-*` headers }) this.app.use(limiter) } // CORS configuration - use provided config or sensible defaults const corsConfig: CorsOptions = httpConfig.cors || { origin: true, // Allow all origins by default credentials: true, exposedHeaders: ["mcp-session-id"], allowedHeaders: ["Content-Type", "mcp-session-id", "accept", "last-event-id", "Authorization"], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] } this.app.use(cors(corsConfig)) // JSON parsing with configurable limits this.app.use(express.json({ limit: httpConfig.jsonLimit || '10mb' })) this.app.use(express.urlencoded({ extended: true, limit: httpConfig.urlencodedLimit || '10mb' })) } private setupRoutes(): void { // ===== OAUTH 2.1 ENDPOINTS ===== // Authorization endpoint this.app.get('/authorize', async (req, res) => { try { const params = { response_type: req.query.response_type as 'code', client_id: req.query.client_id as string, redirect_uri: req.query.redirect_uri as string, scope: req.query.scope as string, state: req.query.state as string, resource: req.query.resource as string, code_challenge: req.query.code_challenge as string, code_challenge_method: req.query.code_challenge_method as 'S256' | 'plain' } const requestContext = { ipAddress: req.ip || req.connection.remoteAddress || '0.0.0.0', userAgent: req.headers['user-agent'] } const result = await this.oauthServer.handleAuthorizationRequest(params, undefined, (req as any).session, requestContext) if ('error' in result) { return res.status(400).json(result) } if ('loginPage' in result) { return res.type('html').send(result.loginPage) } if ('redirectUrl' in result) { res.redirect(result.redirectUrl) } } catch (error) { console.error('Authorization error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Authorization endpoint (POST - handle login form submission) this.app.post('/authorize', async (req, res) => { try { const params = { response_type: req.body.response_type as 'code', client_id: req.body.client_id as string, redirect_uri: req.body.redirect_uri as string, scope: req.body.scope as string, state: req.body.state as string, resource: req.body.resource as string, code_challenge: req.body.code_challenge as string, code_challenge_method: req.body.code_challenge_method as 'S256' | 'plain' } const credentials = req.body.username && req.body.password ? { username: req.body.username as string, password: req.body.password as string } : undefined const requestContext = { ipAddress: req.ip || req.connection.remoteAddress || '0.0.0.0', userAgent: req.headers['user-agent'] } const result = await this.oauthServer.handleAuthorizationRequest(params, credentials, (req as any).session, requestContext) if ('error' in result) { return res.status(400).json(result) } if ('loginPage' in result) { return res.type('html').send(result.loginPage) } if ('redirectUrl' in result) { res.redirect(result.redirectUrl) } } catch (error) { console.error('Authorization error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token endpoint this.app.post('/token', async (req, res) => { try { const params = { grant_type: req.body.grant_type as 'authorization_code' | 'refresh_token' | 'client_credentials', client_id: req.body.client_id as string, client_secret: req.body.client_secret as string, code: req.body.code as string, redirect_uri: req.body.redirect_uri as string, refresh_token: req.body.refresh_token as string, scope: req.body.scope as string, resource: req.body.resource as string, code_verifier: req.body.code_verifier as string } const result = await this.oauthServer.handleTokenRequest(params) if ('error' in result) { return res.status(400).json(result) } res.json(result) } catch (error) { console.error('Token error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token introspection endpoint this.app.post('/introspect', async (req, res) => { try { const { token } = req.body if (!token) { return res.status(400).json({ error: 'invalid_request', error_description: 'token parameter is required' }) } const result = await this.oauthServer.introspectToken(token) res.json(result) } catch (error) { console.error('Introspection error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Token revocation endpoint this.app.post('/revoke', async (req, res) => { try { const { token, client_id } = req.body if (!token || !client_id) { return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id parameters are required' }) } const result = await this.oauthServer.revokeToken(token, client_id) res.json(result) } catch (error) { console.error('Revocation error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // User info endpoint this.app.get('/userinfo', async (req, res) => { try { const authHeader = req.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'invalid_token', error_description: 'Bearer token required' }) } const token = authHeader.substring(7) const result = await this.oauthServer.getUserInfo(token) if ('error' in result) { return res.status(401).json(result) } res.json(result) } catch (error) { console.error('User info error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Dynamic client registration endpoint (RFC 7591) this.app.post('/register', async (req, res) => { try { const result = await this.oauthServer.registerClient(req.body) if ('error' in result) { return res.status(400).json(result) } res.status(201).json(result) } catch (error) { console.error('Client registration error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // ===== DISCOVERY ENDPOINTS ===== // OAuth Authorization Server Metadata (RFC 8414) this.app.get('/.well-known/oauth-authorization-server', (req, res) => { const metadata = this.oauthServer.getAuthorizationServerMetadata() res.json(metadata) }) // JWKS endpoint this.app.get('/.well-known/jwks.json', (req, res) => { // For now, return empty JWKS since we're using HMAC // In production, you'd use RSA keys res.json({ keys: [] }) }) // MCP Protected Resource Metadata (RFC 9728) - FIXED ENDPOINT this.app.get('/.well-known/oauth-protected-resource', (req, res) => { const metadata = this.oauthServer.getProtectedResourceMetadata() res.json(metadata) }) // ===== ADMIN ENDPOINTS ===== // Health check this.app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'mcp-oauth-server', version: '1.0.0', timestamp: new Date().toISOString() }) }) // List clients this.app.get('/admin/clients', async (req, res) => { try { const clients = await this.oauthServer.listClients() res.json(clients) } catch (error) { console.error('List clients error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // List users this.app.get('/admin/users', async (req, res) => { try { const users = await this.oauthServer.listUsers() res.json(users) } catch (error) { console.error('List users error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // Server stats this.app.get('/admin/stats', async (req, res) => { try { const stats = await this.oauthServer.getStats() res.json(stats) } catch (error) { console.error('Stats error:', error) res.status(500).json({ error: 'server_error', error_description: 'Internal server error' }) } }) // 404 handler // this.app.use('*', (req, res) => { // res.status(404).json({ // error: 'not_found', // error_description: 'Endpoint not found', // path: req.originalUrl // }) // }) } private setupErrorHandling(): void { // Global error handler this.app.use((error: any, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error('Unhandled error:', error) // Don't leak error details in production const isDevelopment = process.env.NODE_ENV === 'development' res.status(500).json({ error: 'server_error', error_description: isDevelopment ? error.message : 'Internal server error', ...(isDevelopment && { stack: error.stack }) }) }) } getApp(): express.Application { return this.app } async start(port: number): Promise<void> { return new Promise((resolve, reject) => { const server = this.app.listen(port, () => { console.log(`MCP OAuth Server running on port ${port}`) console.log(`Health check: http://localhost:${port}/health`) console.log(`Authorization endpoint: http://localhost:${port}/authorize`) console.log(`Token endpoint: http://localhost:${port}/token`) console.log(`Discovery: http://localhost:${port}/.well-known/oauth-authorization-server`) resolve() }) server.on('error', (error) => { console.error('Failed to start server:', error) reject(error) }) }) } }