UNPKG

feishu-mcp

Version:

Model Context Protocol server for Feishu integration

178 lines (177 loc) 9.17 kB
import axios from 'axios'; import { Logger } from '../../utils/logger.js'; import { Config } from '../../utils/config.js'; import { TokenCacheManager } from '../../utils/auth/tokenCacheManager.js'; import { ScopeInsufficientError } from '../../utils/error.js'; import { getRequiredScopes, MODULE_SCOPES } from '../constants/feishuScopes.js'; import { ModuleRegistry } from '../../modules/ModuleRegistry.js'; /** * 飞书应用权限 Scope 校验器 * 根据已启用的功能模块动态计算所需权限,仅校验最小权限集 */ export class FeishuScopeValidator { async getApplicationScopes(accessToken, authType) { try { const baseUrl = 'https://open.feishu.cn/open-apis'; const endpoint = '/application/v6/scopes'; const headers = { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; Logger.debug('请求应用权限范围:', endpoint); const response = await axios.get(`${baseUrl}${endpoint}`, { headers }); const data = response.data; if (data.code !== 0) { throw new Error(`获取应用权限范围失败:${data.msg || '未知错误'} (错误码: ${data.code})`); } const scopes = []; if (data.data && Array.isArray(data.data.scopes)) { for (const scopeItem of data.data.scopes) { if (scopeItem.grant_status === 1 && scopeItem.scope_type === authType && scopeItem.scope_name) { scopes.push(scopeItem.scope_name); } } } Logger.debug(`获取应用权限范围成功,共 ${scopes.length}${authType}权限`); return scopes; } catch (error) { Logger.error('获取应用权限范围失败:', error); throw new Error('获取应用权限范围失败: ' + (error instanceof Error ? error.message : String(error))); } } validateScopes(requiredScopes, actualScopes) { const actualScopesSet = new Set(actualScopes); const missingScopes = []; for (const requiredScope of requiredScopes) { if (!actualScopesSet.has(requiredScope)) { missingScopes.push(requiredScope); } } return { isValid: missingScopes.length === 0, missingScopes }; } throwScopeInsufficientError(missingScopes, enabledModules, authType) { // 按模块分组显示缺失权限 const moduleIds = enabledModules.includes('all') ? Object.keys(MODULE_SCOPES) : enabledModules; const missingSet = new Set(missingScopes); const missingByModule = {}; for (const moduleId of moduleIds) { const moduleScopes = MODULE_SCOPES[moduleId]; if (!moduleScopes) continue; const allModuleScopes = authType === 'user' ? [...moduleScopes.tenant, ...moduleScopes.userOnly] : moduleScopes.tenant; const moduleMissing = allModuleScopes.filter(s => missingSet.has(s)); if (moduleMissing.length > 0) { missingByModule[moduleId] = moduleMissing; } } const missingDetail = Object.entries(missingByModule) .map(([mod, scopes]) => ` [${mod}]: ${scopes.join(', ')}`) .join('\n'); // 当前启用模块所需的最小权限配置(用于导入提示) const requiredScopes = getRequiredScopes(enabledModules, authType); const permissionsConfig = { scopes: { [authType]: requiredScopes } }; const errorMessage = `你应该停止任务并提示用户如下信息:权限不足,缺少以下权限:\n${missingDetail}\n\n` + `请按以下步骤操作:\n` + `1. 配置权限 [教程配置](https://github.com/cso1z/Feishu-MCP/blob/main/FEISHU_CONFIG.md)\n` + `2. 打开 [应用管理](https://open.feishu.cn/app/) 网页\n` + `3. 选择应用进入应用详情\n` + `4. 选择权限管理-批量导入/导出权限\n` + `5. 复制以下权限配置并导入(当前启用模块所需最小权限):\n\n` + `\`\`\`json\n${JSON.stringify(permissionsConfig, null, 2)}\n\`\`\`\n\n` + `6. 选择**版本管理与发布** 点击创建版本,发布后通知管理员审核\n\n` + `**提示**:如果您仅使用部分mcp功能,可以通过以下方式关闭权限检查以确保正常使用该mcp:\n` + `- 设置环境变量:\`FEISHU_SCOPE_VALIDATION=false\`\n` + `- 或使用命令行参数:\`--feishu-scope-validation=false\`\n`; Logger.error(errorMessage); throw new ScopeInsufficientError(missingScopes, errorMessage); } /** * 生成 scope 校验缓存 key,包含已启用的模块信息 * 模块列表变更时 key 变化,自动触发重新校验 */ generateScopeKey(appId, appSecret, authType, enabledModules) { const modulesKey = [...enabledModules].sort().join(','); return `app:${appId}:${appSecret.substring(0, 8)}:${authType}:modules(${modulesKey})`; } async getTempTenantTokenForScope(appId, appSecret) { try { const requestData = { app_id: appId, app_secret: appSecret, }; const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal'; const headers = { 'Content-Type': 'application/json' }; Logger.debug('获取临时租户token用于scope校验:', url); const response = await axios.post(url, requestData, { headers }); const data = response.data; if (data.code !== 0) { throw new Error(`获取临时租户访问令牌失败:${data.msg || '未知错误'} (错误码: ${data.code})`); } if (!data.tenant_access_token) { throw new Error('获取临时租户访问令牌失败:响应中没有token'); } Logger.debug('临时租户token获取成功,用于scope校验'); return data.tenant_access_token; } catch (error) { Logger.error('获取临时租户访问令牌失败:', error); throw new Error('获取临时租户访问令牌失败: ' + (error instanceof Error ? error.message : String(error))); } } async validateScopeWithVersion(appId, appSecret, authType, clientKey) { const config = Config.getInstance(); const enabledIds = config.features.enabledModules; const effectiveModules = ModuleRegistry.getEnabledModules(enabledIds, authType).map(m => m.id); const tokenCacheManager = TokenCacheManager.getInstance(); const scopeKey = this.generateScopeKey(appId, appSecret, authType, effectiveModules); const scopeVersion = '3.0.0'; if (!tokenCacheManager.shouldValidateScope(scopeKey, scopeVersion)) { Logger.debug(`Scope版本已校验过,跳过校验: ${scopeKey}`); return; } Logger.info(`开始校验scope权限,版本: ${scopeVersion}, 有效模块: ${effectiveModules.join(', ')}`); try { const tempTenantToken = await this.getTempTenantTokenForScope(appId, appSecret); const actualScopes = await this.getApplicationScopes(tempTenantToken, authType); const requiredScopes = getRequiredScopes(effectiveModules, authType); const validationResult = this.validateScopes(requiredScopes, actualScopes); if (!validationResult.isValid) { if (clientKey) { if (authType === 'user') { tokenCacheManager.removeUserToken(clientKey); Logger.info(`已清除当前用户 token(scope 不足): ${clientKey}`); } else { tokenCacheManager.removeTenantToken(clientKey); Logger.info(`已清除当前租户 token(scope 不足): ${clientKey}`); } } this.throwScopeInsufficientError(validationResult.missingScopes, effectiveModules, authType); } const scopeVersionInfo = { scopeVersion, scopeList: requiredScopes, validatedAt: Math.floor(Date.now() / 1000), validatedVersion: scopeVersion }; tokenCacheManager.saveScopeVersionInfo(scopeKey, scopeVersionInfo); Logger.info(`Scope权限校验成功,版本: ${scopeVersion},共 ${requiredScopes.length} 个权限`); } catch (error) { if (error instanceof ScopeInsufficientError) { throw error; } Logger.warn(`Scope权限校验失败,但继续使用token: ${error instanceof Error ? error.message : String(error)}`); } } }