/** * Authentication and authorization for MCP */ import { MCPAuthConfig, MCPSession } from "../utils/types.ts"; import { ILogger } from "../core/logger.ts"; import { MCPError } from "../utils/errors.ts"; import { createHash, timingSafeEqual } from 'node:crypto'; export interface IAuthManager { authenticate(credentials: unknown): Promise; authorize(session: MCPSession, permission: string): boolean; validateToken(token: string): Promise; generateToken(userId: string, permissions: string[]): Promise; revokeToken(token: string): Promise; } export interface AuthResult { success: boolean; user?: string; permissions?: string[]; token?: string; error?: string; } export interface TokenValidation { valid: boolean; user?: string; permissions?: string[]; expiresAt?: Date; error?: string; } /** * Authentication manager implementation */ export class AuthManager implements IAuthManager { private revokedTokens = new Set(); private tokenStore = new Map(); private cleanupInterval?: NodeJS.Timeout; constructor( private config: MCPAuthConfig, private logger: ILogger, ) { // Start token cleanup timer if (config.enabled) { this.cleanupInterval = setInterval(() => { this.cleanupExpiredTokens(); }, 300000); // Clean up every 5 minutes } } destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } this.revokedTokens.clear(); this.tokenStore.clear(); } async authenticate(credentials: unknown): Promise { if (!this.config.enabled) { return { success: true, user: 'anonymous', permissions: ['*'], }; } this.logger.debug('Authenticating credentials', { method: this.config.method, hasCredentials: !!credentials, }); try { switch (this.config.method) { case 'token': return await this.authenticateToken(credentials); case 'basic': return await this.authenticateBasic(credentials); case 'oauth': return await this.authenticateOAuth(credentials); default: return { success: false, error: `Unsupported authentication method: ${this.config.method}`, }; } } catch (error) { this.logger.error('Authentication error', error); return { success: false, error: error instanceof Error ? error.message : 'Authentication failed', }; } } authorize(session: MCPSession, permission: string): boolean { if (!this.config.enabled || !session.authenticated) { return !this.config.enabled; // If auth disabled, allow all } const permissions = session.authData?.permissions || []; // Check for wildcard permission if (permissions.includes('*')) { return true; } // Check for exact permission match if (permissions.includes(permission)) { return true; } // Check for prefix-based permissions (e.g., "tools.*" matches "tools.list") for (const perm of permissions) { if (perm.endsWith('*') && permission.startsWith(perm.slice(0, -1))) { return true; } } this.logger.warn('Authorization denied', { sessionId: session.id, user: session.authData?.user, permission, userPermissions: permissions, }); return false; } async validateToken(token: string): Promise { if (this.revokedTokens.has(token)) { return { valid: false, error: 'Token has been revoked', }; } const tokenData = this.tokenStore.get(token); if (!tokenData) { return { valid: false, error: 'Invalid token', }; } if (tokenData.expiresAt < new Date()) { this.tokenStore.delete(token); return { valid: false, error: 'Token has expired', }; } return { valid: true, user: tokenData.user, permissions: tokenData.permissions, expiresAt: tokenData.expiresAt, }; } async generateToken(userId: string, permissions: string[]): Promise { const token = this.createSecureToken(); const now = new Date(); const expiresAt = new Date(now.getTime() + (this.config.sessionTimeout || 3600000)); this.tokenStore.set(token, { user: userId, permissions, createdAt: now, expiresAt, }); this.logger.info('Token generated', { userId, permissions, expiresAt, }); return token; } async revokeToken(token: string): Promise { this.revokedTokens.add(token); this.tokenStore.delete(token); this.logger.info('Token revoked', { token: token.substring(0, 8) + '...' }); } private async authenticateToken(credentials: unknown): Promise { const token = this.extractToken(credentials); if (!token) { return { success: false, error: 'Token not provided', }; } // Check if it's a stored token (generated by us) const validation = await this.validateToken(token); if (validation.valid) { return { success: true, user: validation.user!, permissions: validation.permissions!, token, }; } // Check against configured static tokens if (this.config.tokens && this.config.tokens.length > 0) { const isValid = this.config.tokens.some((validToken) => { return this.timingSafeEqual(token, validToken); }); if (isValid) { return { success: true, user: 'token-user', permissions: ['*'], // Static tokens get all permissions token, }; } } return { success: false, error: 'Invalid token', }; } private async authenticateBasic(credentials: unknown): Promise { const { username, password } = this.extractBasicAuth(credentials); if (!username || !password) { return { success: false, error: 'Username and password required', }; } if (!this.config.users || this.config.users.length === 0) { return { success: false, error: 'No users configured', }; } const user = this.config.users.find((u) => u.username === username); if (!user) { return { success: false, error: 'Invalid username or password', }; } // Verify password const isValidPassword = this.verifyPassword(password, user.password); if (!isValidPassword) { return { success: false, error: 'Invalid username or password', }; } // Generate a session token const token = await this.generateToken(username, user.permissions); return { success: true, user: username, permissions: user.permissions, token, }; } private async authenticateOAuth(credentials: unknown): Promise { // Extract token from credentials const token = this.extractToken(credentials); if (!token) { return { success: false, error: 'JWT token not provided', }; } try { // Validate JWT token structure (header.payload.signature) const parts = token.split('.'); if (parts.length !== 3) { return { success: false, error: 'Invalid JWT token format', }; } // Decode the payload (middle part) const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); // Check token expiration const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { return { success: false, error: 'Token has expired', }; } // Check required claims if (!payload.sub) { return { success: false, error: 'Token missing required claims', }; } // Extract permissions from scope or custom claims const permissions = this.extractPermissionsFromPayload(payload); // In a real implementation, we would verify the signature here // using the OAuth provider's public key return { success: true, user: payload.sub, permissions, token, }; } catch (error) { this.logger.error('OAuth authentication error', error); return { success: false, error: error instanceof Error ? error.message : 'OAuth authentication failed', }; } } private extractPermissionsFromPayload(payload: any): string[] { // Extract permissions from standard OAuth claims const permissions: string[] = []; // From scope (space-separated string) if (typeof payload.scope === 'string') { permissions.push(...payload.scope.split(' ')); } // From custom claims for permissions/roles if (Array.isArray(payload.permissions)) { permissions.push(...payload.permissions); } if (Array.isArray(payload.roles)) { // Map roles to permissions based on convention // e.g., 'admin' role gets '*' permission if (payload.roles.includes('admin')) { permissions.push('*'); } // Add other role-based permissions if (payload.roles.includes('user')) { permissions.push('tools.list', 'tools.invoke'); } } return [...new Set(permissions)]; // Remove duplicates } private extractToken(credentials: unknown): string | null { if (typeof credentials === 'string') { return credentials; } if (typeof credentials === 'object' && credentials !== null) { const creds = credentials as Record; if (typeof creds.token === 'string') { return creds.token; } if (typeof creds.authorization === 'string') { const match = creds.authorization.match(/^Bearer\s+(.+)$/i); return match ? match[1] : null; } } return null; } private extractBasicAuth(credentials: unknown): { username?: string; password?: string } { if (typeof credentials === 'object' && credentials !== null) { const creds = credentials as Record; if (typeof creds.username === 'string' && typeof creds.password === 'string') { return { username: creds.username, password: creds.password, }; } if (typeof creds.authorization === 'string') { const match = creds.authorization.match(/^Basic\s+(.+)$/i); if (match) { try { const decoded = atob(match[1]); const colonIndex = decoded.indexOf(':'); if (colonIndex >= 0) { return { username: decoded.substring(0, colonIndex), password: decoded.substring(colonIndex + 1), }; } } catch { // Invalid base64 } } } } return {}; } private verifyPassword(providedPassword: string, storedPassword: string): boolean { // For now, using simple hash comparison // In production, use proper password hashing like bcrypt const hashedProvided = this.hashPassword(providedPassword); const hashedStored = this.hashPassword(storedPassword); return this.timingSafeEqual(hashedProvided, hashedStored); } private hashPassword(password: string): string { return createHash('sha256').update(password).digest('hex'); } private timingSafeEqual(a: string, b: string): boolean { const encoder = new TextEncoder(); const bufferA = encoder.encode(a); const bufferB = encoder.encode(b); if (bufferA.length !== bufferB.length) { return false; } return timingSafeEqual(bufferA, bufferB); } private createSecureToken(): string { // Generate a secure random token const timestamp = Date.now().toString(36); const random1 = Math.random().toString(36).substring(2, 15); const random2 = Math.random().toString(36).substring(2, 15); const hash = createHash('sha256') .update(`${timestamp}${random1}${random2}`) .digest('hex') .substring(0, 32); return `mcp_${timestamp}_${hash}`; } private cleanupExpiredTokens(): void { const now = new Date(); let cleaned = 0; for (const [token, data] of this.tokenStore.entries()) { if (data.expiresAt < now) { this.tokenStore.delete(token); cleaned++; } } if (cleaned > 0) { this.logger.debug('Cleaned up expired tokens', { count: cleaned }); } } } /** * Permission constants for common operations */ export const Permissions = { // System operations SYSTEM_INFO: 'system.info', SYSTEM_HEALTH: 'system.health', SYSTEM_METRICS: 'system.metrics', // Tool operations TOOLS_LIST: 'tools.list', TOOLS_INVOKE: 'tools.invoke', TOOLS_DESCRIBE: 'tools.describe', // Agent operations AGENTS_LIST: 'agents.list', AGENTS_SPAWN: 'agents.spawn', AGENTS_TERMINATE: 'agents.terminate', AGENTS_INFO: 'agents.info', // Task operations TASKS_LIST: 'tasks.list', TASKS_CREATE: 'tasks.create', TASKS_CANCEL: 'tasks.cancel', TASKS_STATUS: 'tasks.status', // Memory operations MEMORY_READ: 'memory.read', MEMORY_WRITE: 'memory.write', MEMORY_QUERY: 'memory.query', MEMORY_DELETE: 'memory.delete', // Administrative operations ADMIN_CONFIG: 'admin.config', ADMIN_LOGS: 'admin.logs', ADMIN_SESSIONS: 'admin.sessions', // Wildcard permission ALL: '*', } as const; export type Permission = typeof Permissions[keyof typeof Permissions];