@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
254 lines (253 loc) • 8.95 kB
JavaScript
;
/**
* 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)));
}