UNPKG

@kya-os/mcp-i

Version:

The TypeScript MCP framework with identity features built-in

254 lines (253 loc) 8.95 kB
"use strict"; /** * Authorization Handshake Module * * Orchestrates the authorization flow for MCP-I bouncer: * 1. Check agent reputation (optional) * 2. Verify delegation exists * 3. Return needs_authorization error if missing * * This module implements the "gatekeeper" logic that determines whether * an agent should be allowed to execute a tool or needs human authorization. * * Flow: * - If delegation exists + valid → allow (fast path) * - If delegation missing → return needs_authorization with hints * - If reputation too low (optional) → require authorization * * Related: PHASE_1_XMCP_I_SERVER.md Epic 2 (Runtime Interceptor) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MemoryResumeTokenStore = void 0; exports.verifyOrHints = verifyOrHints; exports.hasSensitiveScopes = hasSensitiveScopes; const runtime_1 = require("@kya-os/contracts/runtime"); /** * Simple in-memory resume token store (for testing/development) */ class MemoryResumeTokenStore { tokens = new Map(); ttl; constructor(ttlMs = 600_000) { this.ttl = ttlMs; } async create(agentDid, scopes, metadata) { const token = `rt_${Date.now()}_${Math.random().toString(36).substr(2, 16)}`; const now = Date.now(); this.tokens.set(token, { agentDid, scopes, createdAt: now, expiresAt: now + this.ttl, metadata, fulfilled: false, }); return token; } async get(token) { const data = this.tokens.get(token); if (!data) return null; // Check expiration if (Date.now() > data.expiresAt) { this.tokens.delete(token); return null; } // Check if already fulfilled if (data.fulfilled) { return null; } return data; } async fulfill(token) { const data = this.tokens.get(token); if (data) { data.fulfilled = true; } } clear() { this.tokens.clear(); } } exports.MemoryResumeTokenStore = MemoryResumeTokenStore; /** * Main auth handshake function * * Verifies agent authorization or returns authorization hints * * @param agentDid - Agent DID requesting access * @param scopes - Required permission scopes * @param config - Auth handshake configuration * @param resumeToken - Optional resume token from previous authorization * @returns Verification result with delegation or auth error */ async function verifyOrHints(agentDid, scopes, config, _resumeToken) { const startTime = Date.now(); if (config.debug) { console.log(`[AuthHandshake] Verifying ${agentDid} for scopes: ${scopes.join(', ')}`); } // Step 1: Check reputation (optional, if KTA configured) let reputation; if (config.kta && config.bouncer.minReputationScore !== undefined) { try { reputation = await fetchAgentReputation(agentDid, config.kta); if (config.debug) { console.log(`[AuthHandshake] Reputation score: ${reputation.score}`); } // If reputation is too low, require authorization if (reputation.score < config.bouncer.minReputationScore) { if (config.debug) { console.log(`[AuthHandshake] Reputation ${reputation.score} < ${config.bouncer.minReputationScore}, requiring authorization`); } const authError = await buildNeedsAuthorizationError(agentDid, scopes, config, 'Agent reputation score below threshold'); return { authorized: false, authError, reputation, reason: 'Low reputation score', }; } } catch (error) { // Don't fail hard on reputation check failure console.warn('[AuthHandshake] Failed to check reputation:', error); } } // Step 2: Check for existing delegation let delegationResult; try { delegationResult = await config.delegationVerifier.verify(agentDid, scopes); } catch (error) { console.error('[AuthHandshake] Delegation verification failed:', error); const errorMessage = `Delegation verification error: ${error instanceof Error ? error.message : 'Unknown error'}`; const authError = await buildNeedsAuthorizationError(agentDid, scopes, config, errorMessage); return { authorized: false, authError, reason: errorMessage, }; } // Step 3: If delegation exists and valid, authorize immediately if (delegationResult.valid && delegationResult.delegation) { if (config.debug) { console.log(`[AuthHandshake] Delegation valid, authorized (${Date.now() - startTime}ms)`); } return { authorized: true, delegation: delegationResult.delegation, reputation, reason: 'Valid delegation found', }; } // Step 4: No delegation found - return needs_authorization error if (config.debug) { console.log(`[AuthHandshake] No delegation found, returning needs_authorization (${Date.now() - startTime}ms)`); } const authError = await buildNeedsAuthorizationError(agentDid, scopes, config, delegationResult.reason || 'No valid delegation found'); return { authorized: false, authError, reputation, reason: delegationResult.reason || 'No delegation', }; } /** * Fetch agent reputation from KTA * * @param agentDid - Agent DID * @param ktaConfig - KTA API configuration * @returns Agent reputation data */ async function fetchAgentReputation(agentDid, ktaConfig) { const apiUrl = ktaConfig.apiUrl.replace(/\/$/, ''); const headers = { 'Content-Type': 'application/json', }; if (ktaConfig.apiKey) { headers['X-API-Key'] = ktaConfig.apiKey; } const response = await fetch(`${apiUrl}/api/v1/reputation/${encodeURIComponent(agentDid)}`, { method: 'GET', headers, }); if (!response.ok) { if (response.status === 404) { // Agent not registered, return default "unknown" reputation return { agentDid, score: 50, // Neutral score totalInteractions: 0, successRate: 0, riskLevel: 'unknown', updatedAt: Date.now(), }; } throw new Error(`KTA API error: ${response.status} ${response.statusText}`); } const data = await response.json(); return { agentDid: data.agentDid || agentDid, score: data.score || 50, totalInteractions: data.totalInteractions || 0, successRate: data.successRate || 0, riskLevel: data.riskLevel || 'unknown', updatedAt: data.updatedAt || Date.now(), }; } /** * Build needs_authorization error with hints * * @param agentDid - Agent DID * @param scopes - Required scopes * @param config - Auth handshake configuration * @param message - Human-readable error message * @returns NeedsAuthorizationError */ async function buildNeedsAuthorizationError(agentDid, scopes, config, message) { // Use the persistent resume token store from config const resumeToken = await config.resumeTokenStore.create(agentDid, scopes, { requestedAt: Date.now(), }); const expiresAt = Date.now() + (config.bouncer.resumeTokenTtl || 600_000); // Build authorization URL const authUrl = new URL(config.bouncer.authorizationUrl); authUrl.searchParams.set('agent_did', agentDid); authUrl.searchParams.set('scopes', scopes.join(',')); authUrl.searchParams.set('resume_token', resumeToken); // Generate short authorization code (for display) const authCode = resumeToken.substring(0, 8).toUpperCase(); // Build display hints const display = { title: 'Authorization Required', hint: ['link', 'qr'], authorizationCode: authCode, qrUrl: `https://chart.googleapis.com/chart?cht=qr&chs=300x300&chl=${encodeURIComponent(authUrl.toString())}`, }; return (0, runtime_1.createNeedsAuthorizationError)({ message, authorizationUrl: authUrl.toString(), resumeToken, expiresAt, scopes, display, }); } /** * Helper: Check if scopes are sensitive and require authorization * * @param scopes - Scopes to check * @returns true if scopes are sensitive */ function hasSensitiveScopes(scopes) { const sensitivePatterns = [ 'write', 'delete', 'admin', 'payment', 'transfer', 'execute', 'modify', ]; return scopes.some((scope) => sensitivePatterns.some((pattern) => scope.toLowerCase().includes(pattern))); }