UNPKG

codecrucible-synth

Version:

Production-Ready AI Development Platform with Multi-Voice Synthesis, Smithery MCP Integration, Enterprise Security, and Zero-Timeout Reliability

669 lines (584 loc) 18.9 kB
/** * OAuth Resource Server for MCP Security (2024 Standards) * Implements RFC 6749 OAuth 2.0 and RFC 8707 Resource Indicators * * Based on 2024 MCP security research: * - OAuth Resource Server classification required for all MCP servers * - Resource Indicators (RFC 8707) provide fine-grained access control * - JWT validation with JWKS endpoint for scalable token verification * - Bearer token authentication with proper scope validation */ import { logger } from '../logger.js'; import { EventEmitter } from 'events'; import crypto from 'crypto'; export interface OAuthConfig { issuer: string; // OAuth Authorization Server URL jwksUri: string; // JSON Web Key Set endpoint audience: string; // Expected audience for tokens requiredScopes: string[]; // Required OAuth scopes resourceIndicators: string[]; // RFC 8707 resource indicators tokenValidation: { enabled: boolean; clockTolerance: number; // seconds maxAge: number; // seconds algorithms: string[]; // Allowed signing algorithms }; caching: { jwksCache: boolean; jwksCacheTtl: number; // seconds tokenCache: boolean; tokenCacheTtl: number; // seconds }; } export interface TokenClaims { iss: string; // Issuer sub: string; // Subject (user ID) aud: string | string[]; // Audience exp: number; // Expiration time iat: number; // Issued at time nbf?: number; // Not before time jti?: string; // JWT ID scope?: string; // OAuth scopes resource?: string | string[]; // Resource indicators (RFC 8707) client_id?: string; // OAuth client ID username?: string; // Username roles?: string[]; // User roles } export interface ValidationResult { valid: boolean; claims?: TokenClaims; scopes: string[]; resourceIndicators: string[]; error?: string; errorDescription?: string; } export interface JWK { kty: string; // Key type use?: string; // Key use alg?: string; // Algorithm kid?: string; // Key ID n?: string; // RSA modulus e?: string; // RSA exponent x?: string; // EC x coordinate y?: string; // EC y coordinate crv?: string; // EC curve k?: string; // Symmetric key } export interface JWKS { keys: JWK[]; } export class OAuthResourceServer extends EventEmitter { private config: OAuthConfig; private jwksCache: Map<string, { jwk: JWK; expires: number }> = new Map(); private tokenCache: Map<string, { result: ValidationResult; expires: number }> = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; constructor(config: OAuthConfig) { super(); this.config = config; this.startCacheCleanup(); logger.info('OAuth Resource Server initialized', { issuer: config.issuer, audience: config.audience, requiredScopes: config.requiredScopes, resourceIndicators: config.resourceIndicators.length }); } /** * Validate OAuth Bearer token according to 2024 MCP standards */ async validateBearerToken(authorizationHeader: string): Promise<ValidationResult> { try { // Extract Bearer token const token = this.extractBearerToken(authorizationHeader); if (!token) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_request', errorDescription: 'Missing or malformed Authorization header' }; } // Check token cache if (this.config.caching.tokenCache) { const cached = this.tokenCache.get(token); if (cached && cached.expires > Date.now()) { logger.debug('Token validation result retrieved from cache'); return cached.result; } } // Validate JWT structure and signature const validationResult = await this.validateJWT(token); // Cache result if configured if (this.config.caching.tokenCache && validationResult.valid) { const cacheExpiry = Date.now() + (this.config.caching.tokenCacheTtl * 1000); this.tokenCache.set(token, { result: validationResult, expires: cacheExpiry }); } // Emit validation event this.emit('token-validated', { valid: validationResult.valid, subject: validationResult.claims?.sub, scopes: validationResult.scopes, resourceIndicators: validationResult.resourceIndicators }); return validationResult; } catch (error) { logger.error('Bearer token validation failed', { error }); return { valid: false, scopes: [], resourceIndicators: [], error: 'server_error', errorDescription: 'Token validation failed due to server error' }; } } /** * Extract Bearer token from Authorization header */ private extractBearerToken(authHeader: string): string | null { if (!authHeader || typeof authHeader !== 'string') { return null; } const parts = authHeader.trim().split(' '); if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { return null; } return parts[1]; } /** * Validate JWT token with comprehensive checks */ private async validateJWT(token: string): Promise<ValidationResult> { try { // Parse JWT structure const parts = token.split('.'); if (parts.length !== 3) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Malformed JWT structure' }; } // Decode header and payload const header = this.decodeBase64Url(parts[0]); const payload = this.decodeBase64Url(parts[1]); const headerObj = JSON.parse(header); const claims: TokenClaims = JSON.parse(payload); // Validate algorithm if (!this.config.tokenValidation.algorithms.includes(headerObj.alg)) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: `Unsupported algorithm: ${headerObj.alg}` }; } // Validate timing claims const now = Math.floor(Date.now() / 1000); const clockTolerance = this.config.tokenValidation.clockTolerance; if (claims.exp && claims.exp < (now - clockTolerance)) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Token has expired' }; } if (claims.nbf && claims.nbf > (now + clockTolerance)) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Token not yet valid' }; } if (claims.iat && this.config.tokenValidation.maxAge > 0) { const maxAge = this.config.tokenValidation.maxAge; if (claims.iat < (now - maxAge)) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Token too old' }; } } // Validate issuer if (claims.iss !== this.config.issuer) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Invalid issuer' }; } // Validate audience const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (!audiences.includes(this.config.audience)) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Invalid audience' }; } // Validate signature const signatureValid = await this.validateSignature(token, headerObj, claims); if (!signatureValid) { return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Invalid token signature' }; } // Extract and validate scopes const scopes = this.extractScopes(claims); const scopesValid = this.validateScopes(scopes); if (!scopesValid) { return { valid: false, scopes, resourceIndicators: [], error: 'insufficient_scope', errorDescription: 'Token lacks required scopes' }; } // Extract and validate resource indicators (RFC 8707) const resourceIndicators = this.extractResourceIndicators(claims); const resourcesValid = this.validateResourceIndicators(resourceIndicators); if (!resourcesValid) { return { valid: false, scopes, resourceIndicators, error: 'invalid_resource', errorDescription: 'Token lacks required resource indicators' }; } logger.debug('JWT validation successful', { subject: claims.sub, client: claims.client_id, scopes: scopes.length, resources: resourceIndicators.length }); return { valid: true, claims, scopes, resourceIndicators }; } catch (error) { logger.error('JWT validation error', { error }); return { valid: false, scopes: [], resourceIndicators: [], error: 'invalid_token', errorDescription: 'Token validation failed' }; } } /** * Validate JWT signature using JWKS */ private async validateSignature(token: string, header: any, claims: TokenClaims): Promise<boolean> { try { // Get signing key from JWKS const jwk = await this.getSigningKey(header.kid); if (!jwk) { logger.warn('Signing key not found', { kid: header.kid }); return false; } // Convert JWK to crypto key const publicKey = await this.jwkToCryptoKey(jwk); // Verify signature based on algorithm const [headerB64, payloadB64, signatureB64] = token.split('.'); const signatureData = `${headerB64}.${payloadB64}`; const signature = this.decodeBase64UrlToBuffer(signatureB64); let isValid = false; switch (header.alg) { case 'RS256': isValid = crypto.verify('sha256', Buffer.from(signatureData), publicKey, signature); break; case 'ES256': isValid = crypto.verify('sha256', Buffer.from(signatureData), publicKey, signature); break; case 'HS256': // HMAC validation (symmetric key) const hmac = crypto.createHmac('sha256', jwk.k!); hmac.update(signatureData); const expectedSignature = hmac.digest(); isValid = crypto.timingSafeEqual(signature, expectedSignature); break; default: logger.warn('Unsupported signing algorithm', { algorithm: header.alg }); return false; } return isValid; } catch (error) { logger.error('Signature validation failed', { error }); return false; } } /** * Get signing key from JWKS endpoint */ private async getSigningKey(keyId?: string): Promise<JWK | null> { try { // Check cache first if (this.config.caching.jwksCache && keyId) { const cached = this.jwksCache.get(keyId); if (cached && cached.expires > Date.now()) { return cached.jwk; } } // Fetch JWKS from endpoint const jwks = await this.fetchJWKS(); // Find appropriate key let selectedKey: JWK | null = null; if (keyId) { selectedKey = jwks.keys.find(key => key.kid === keyId) || null; } else { // Use first available key if no kid specified selectedKey = jwks.keys[0] || null; } // Cache the key if (selectedKey && keyId && this.config.caching.jwksCache) { const cacheExpiry = Date.now() + (this.config.caching.jwksCacheTtl * 1000); this.jwksCache.set(keyId, { jwk: selectedKey, expires: cacheExpiry }); } return selectedKey; } catch (error) { logger.error('Failed to get signing key', { keyId, error }); return null; } } /** * Fetch JWKS from the configured endpoint */ private async fetchJWKS(): Promise<JWKS> { try { // In a real implementation, this would be an HTTP request // For now, return a mock JWKS for development const mockJWKS: JWKS = { keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid: 'mock-key-1', n: 'mock-rsa-modulus', e: 'AQAB' }] }; logger.debug('JWKS fetched successfully', { keysCount: mockJWKS.keys.length }); return mockJWKS; } catch (error) { logger.error('Failed to fetch JWKS', { jwksUri: this.config.jwksUri, error }); throw error; } } /** * Convert JWK to Node.js crypto key */ private async jwkToCryptoKey(jwk: JWK): Promise<any> { // This is a simplified version - in production you'd use a proper JWK library switch (jwk.kty) { case 'RSA': // Convert JWK RSA key to PEM format // In production, use a library like node-jose or crypto.createPublicKey return `-----BEGIN PUBLIC KEY-----\n${jwk.n}\n-----END PUBLIC KEY-----`; case 'EC': // Convert JWK EC key to PEM format return `-----BEGIN PUBLIC KEY-----\n${jwk.x}${jwk.y}\n-----END PUBLIC KEY-----`; case 'oct': // Symmetric key return Buffer.from(jwk.k!, 'base64url'); default: throw new Error(`Unsupported key type: ${jwk.kty}`); } } /** * Extract OAuth scopes from token claims */ private extractScopes(claims: TokenClaims): string[] { if (!claims.scope) return []; // Scopes can be space-separated string or array if (typeof claims.scope === 'string') { return claims.scope.split(' ').filter(s => s.length > 0); } // Handle array case with explicit typing const scope = claims.scope as any; if (Array.isArray(scope)) { return scope.filter((s: any) => typeof s === 'string' && s.length > 0); } return []; } /** * Validate required OAuth scopes */ private validateScopes(tokenScopes: string[]): boolean { // Check if token has all required scopes for (const requiredScope of this.config.requiredScopes) { if (!tokenScopes.includes(requiredScope)) { logger.debug('Missing required scope', { required: requiredScope, available: tokenScopes }); return false; } } return true; } /** * Extract resource indicators from token claims (RFC 8707) */ private extractResourceIndicators(claims: TokenClaims): string[] { if (!claims.resource) return []; if (typeof claims.resource === 'string') { return [claims.resource]; } if (Array.isArray(claims.resource)) { return claims.resource.filter(r => typeof r === 'string' && r.length > 0); } return []; } /** * Validate resource indicators (RFC 8707) */ private validateResourceIndicators(tokenResources: string[]): boolean { if (this.config.resourceIndicators.length === 0) { return true; // No resources required } // Check if token has at least one required resource indicator for (const requiredResource of this.config.resourceIndicators) { if (tokenResources.includes(requiredResource)) { return true; } } logger.debug('Missing required resource indicators', { required: this.config.resourceIndicators, available: tokenResources }); return false; } /** * Decode base64url without padding */ private decodeBase64Url(input: string): string { // Add padding if needed let padded = input; while (padded.length % 4) { padded += '='; } // Replace URL-safe characters const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); return Buffer.from(base64, 'base64').toString('utf8'); } /** * Decode base64url to buffer */ private decodeBase64UrlToBuffer(input: string): Buffer { let padded = input; while (padded.length % 4) { padded += '='; } const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); return Buffer.from(base64, 'base64'); } /** * Start cache cleanup interval */ private startCacheCleanup(): void { if (this.cleanupInterval) return; this.cleanupInterval = setInterval(() => { this.cleanupExpiredEntries(); }, 60000); // Clean every minute } /** * Clean up expired cache entries */ private cleanupExpiredEntries(): void { const now = Date.now(); let cleaned = 0; // Clean JWKS cache for (const [key, entry] of this.jwksCache.entries()) { if (entry.expires <= now) { this.jwksCache.delete(key); cleaned++; } } // Clean token cache for (const [key, entry] of this.tokenCache.entries()) { if (entry.expires <= now) { this.tokenCache.delete(key); cleaned++; } } if (cleaned > 0) { logger.debug('Cache cleanup completed', { entriesRemoved: cleaned }); } } /** * Get OAuth server metrics */ getMetrics(): { jwksCacheSize: number; tokenCacheSize: number; cacheHitRate: number; validationCount: number; errorCount: number; } { // In production, these would be tracked metrics return { jwksCacheSize: this.jwksCache.size, tokenCacheSize: this.tokenCache.size, cacheHitRate: 0.85, // Mock value validationCount: 0, // Would be tracked errorCount: 0 // Would be tracked }; } /** * Shutdown OAuth Resource Server */ shutdown(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.jwksCache.clear(); this.tokenCache.clear(); this.removeAllListeners(); logger.info('OAuth Resource Server shutdown completed'); } } // Default OAuth configuration for MCP servers (2024 standards) export const defaultOAuthConfig: OAuthConfig = { issuer: 'https://auth.example.com', jwksUri: 'https://auth.example.com/.well-known/jwks.json', audience: 'mcp-server', requiredScopes: ['mcp:read', 'mcp:execute'], resourceIndicators: ['urn:mcp:server:codecrucible-synth'], tokenValidation: { enabled: true, clockTolerance: 30, // 30 seconds maxAge: 3600, // 1 hour algorithms: ['RS256', 'ES256'] }, caching: { jwksCache: true, jwksCacheTtl: 300, // 5 minutes tokenCache: true, tokenCacheTtl: 60 // 1 minute } };