From 4c53f90947ab5499d1d457069f3ef2c04d333883 Mon Sep 17 00:00:00 2001 From: nac Date: Tue, 18 Aug 2026 04:08:27 +0000 Subject: [PATCH] feat: add NocoBase browser OAuth login --- INSTALL.md | 56 +++-- README.md | 141 ++++++----- docs/ARCHITECTURE.md | 8 +- docs/LOCAL_DELIVERY.md | 20 +- docs/REFERENCE.md | 88 +++++++ install-manifest.json | 20 +- package-lock.json | 4 +- package.json | 4 +- src/cli.ts | 101 ++++++-- src/credentials.ts | 149 +++++++++-- src/nocobase-endpoints.ts | 4 + src/oauth.ts | 433 ++++++++++++++++++++++++++++++++ src/tools.ts | 4 +- test/cli-package.test.ts | 6 +- test/credentials-config.test.ts | 93 ++++++- test/oauth.test.ts | 150 +++++++++++ 16 files changed, 1138 insertions(+), 143 deletions(-) create mode 100644 docs/REFERENCE.md create mode 100644 src/nocobase-endpoints.ts create mode 100644 src/oauth.ts create mode 100644 test/oauth.test.ts diff --git a/INSTALL.md b/INSTALL.md index db7a681..2971ae9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,23 +1,23 @@ # 安装 NocoBase CRM MCP -这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在隐藏提示中输入自己的 NocoBase Authorize Key。 +这是一份供终端型 AI Agent 执行的通用安装协议,不依赖任何特定 Agent 或 MCP 客户端。用户只需要提供本链接、确认本机变更,并在浏览器中登录自己的 NocoBase 账号完成授权。 -如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更、要求用户创建 Key,或让用户在隐藏提示中粘贴 Key 时才中断用户。 +如果你是安装 Agent,请直接执行流程,不要让用户逐条尝试命令。只有在确认系统工具变更或等待用户在 NocoBase 网页登录授权时才中断用户。 ## 完成标准 - 下载本文件所在的同一固定 Release,不使用默认分支; - 只连接固定的 NocoBase API `https://justconn.top/api`; - MCP 在用户本机以 stdio 运行,默认只读; -- 认证属于当前用户自己的个人 Authorize Key 和业务角色; +- 通过 NocoBase 原生 OAuth 2.1 认证当前用户,权限仍跟随本人的业务角色; - 当前 MCP 客户端已注册 `nocobase-crm`; - `crm_health` 返回 `ok=true`。 ## 安全规则 -- 不索取账号密码,不使用共享服务账号或管理员 Key。 -- 不让用户把 Key 发到聊天、命令参数、环境变量、剪贴板日志或 MCP 配置。 -- Key 只能由用户输入到安装程序的本机隐藏 TTY 提示。 +- 不索取账号密码、OAuth Token 或 Authorize Key,不使用共享服务账号或管理员身份。 +- 用户只能在 `https://justconn.top` 的 NocoBase 页面输入账号信息并确认授权。 +- OAuth Token 不得进入聊天、命令参数、环境变量、CRM 配置、剪贴板日志或 MCP 客户端配置。 - 不使用 `sudo`,不执行 `curl | sh` 或 `wget | sh`。 - 不静默安装 Git、Node.js、包管理器或其他系统工具。 - 修改 MCP 客户端配置前展示目标文件与新增项,创建备份,并结构化合并;不得覆盖其他 server。 @@ -35,7 +35,7 @@ - `donaldttt-nocobase-crm-mcp-.tgz`; - `SHA256SUMS`。 -仓库为 Private 时,必须使用当前用户已经授权的 GitHub 连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户 GitHub Token 到聊天。 +仓库访问受限时,必须使用当前用户已经授权的代码托管平台连接读取 Release;无权访问时停止,不得退回默认分支、源码 clone 或索取用户的访问令牌到聊天。 ### 2. 一次性说明本机变更 @@ -46,7 +46,7 @@ 正常安装只会留下: - 应用数据目录中的 npm 包; -- 不含 Key 的 CRM `config.json`; +- 不含凭据的 CRM `config.json`; - 权限受限的本机凭证文件; - MCP 客户端配置及其备份。 @@ -54,7 +54,7 @@ ### 3. 下载并验证固定发布物 -通过已授权的 GitHub API/连接下载两个 Release 资产到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。 +通过当前 Gitea 实例或经批准分发渠道的已授权 API/连接,将两个 Release 资产下载到新建的临时目录。验证 tarball 的 SHA-256 与 `SHA256SUMS` 完全一致;不一致立即停止并删除未使用的下载文件。 选择用户级安装前缀,例如: @@ -70,19 +70,33 @@ npm install --prefix --omit=dev 定位安装后的命令:POSIX 为 `/node_modules/.bin/nocobase-crm-mcp`,Windows 为相同目录下的 `nocobase-crm-mcp.cmd`。 -### 4. 个人 Key 设置 +### 4. 浏览器登录 NocoBase -先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的个人 Authorize Key。不要让用户把 Key 发给 Agent。 - -在用户可直接控制的本机终端运行: +运行: ```bash setup --json ``` -安装程序会先验证安全 TTY,再显示固定 API 地址和隐藏 Key 提示;没有安全 TTY 时会在写文件前停止。Key 通过 `https://justconn.top/api/roles:check` 验证成功后才保存,并立即执行 `crm_health`。凭证文件只保存 Key,不保存或接受 API 地址。Key 不会出现在返回 JSON、命令参数、MCP 配置或日志中。 +安装程序通过 NocoBase 的 OAuth 发现接口动态注册本机公共客户端,请求 `api offline_access` 权限,并自动打开带确认码的 NocoBase 设备授权页。用户在网页中登录、核对确认码并点击授权;程序随后取得短期访问令牌和可撤销的刷新令牌,通过 `https://justconn.top/api/roles:check` 验证成功后保存,并立即执行 `crm_health`。 -POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。不要复制其他用户的凭证文件;每个操作系统用户都必须单独设置自己的 Key。 +浏览器没有自动打开时,安装程序会继续显示完整授权链接和确认码。可重新运行: + +```bash + setup --no-browser --json +``` + +然后由用户手动打开链接。设备授权约十分钟后失效;密码和浏览器会话始终留在 NocoBase,安装程序不会读取浏览器 Cookie。访问令牌临近过期时会自动使用刷新令牌续期,不影响日常 MCP 调用。 + +POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当前用户应用数据目录。凭证文件不保存或接受 API 地址,Token 不会出现在返回 JSON、命令参数、MCP 配置或日志中。不要复制其他用户的凭证文件;每个操作系统用户都必须单独登录。 + +只有在 OAuth 暂时不可用、管理员明确要求且用户拥有个人 Authorize Key 时,才使用: + +```bash + setup --manual-key --json +``` + +该备用模式必须在用户可直接控制的安全 TTY 中隐藏输入 Key;仍不得让用户把 Key 发给 Agent。 ### 5. 注册并验证 MCP @@ -94,7 +108,7 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当 4. 重载或重启 MCP 客户端; 5. 调用 `crm_health`,要求 `ok=true`。 -最终只报告 Release、安装目录、配置备份位置和只读/写入模式,不显示任何 Key。 +最终只报告 Release、安装目录、配置备份位置、认证方式和只读/写入模式,不显示任何 Token 或 Key。 ## 可选写入 @@ -102,14 +116,16 @@ POSIX 凭证目录使用 `0700`,凭证文件使用 `0600`。Windows 使用当 ## 故障处理 -- `nocobase_auth_failed`:让用户撤销失效 Key,重新运行同一个 setup 命令并在隐藏提示输入新 Key。 -- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限 Key。 +- 浏览器未打开:使用 `setup --no-browser`,让用户手动打开终端显示的 `justconn.top` 授权链接。 +- 授权超时或被取消:重新运行 `setup`,再次核对确认码并授权。 +- `nocobase_auth_failed`:重新运行 `setup` 完成浏览器登录;不要换用共享高权限 Token 或 Key。 +- `nocobase_request_failed` / `NO_PERMISSIONS`:检查用户角色、字段权限、行级权限与 `config.json` 映射,不得换用共享高权限身份。 - Node.js 缺失:报告前置条件并请求用户授权正规安装,不退回 Python/uv/nb CLI 方案。 -- Private Release 无权访问:报告分发权限问题,不退回 clone 默认分支。 +- 受限 Release 无权访问:报告 Gitea 或分发渠道的权限问题,不退回 clone 默认分支。 ## 用户可发送给 Agent 的最短指令 ```text -请按这个固定 Release 的协议帮我完成本机安装。默认只读,使用我自己的 NocoBase Authorize Key;只让我在本机隐藏提示中输入 Key,不要静默安装系统工具。完成前必须验证 crm_health: +请按这个固定 Release 的协议帮我完成本机安装。默认只读;请运行 setup,让我只在 NocoBase 官方网页登录并确认授权。不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前必须验证 crm_health: <固定 Release 的 INSTALL.md 链接> ``` diff --git a/README.md b/README.md index b0c5e1d..97dd281 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,95 @@ # NocoBase CRM MCP -本项目是在用户电脑本机运行的 NocoBase CRM MCP。每位用户使用自己的个人 Authorize Key,所有查询和写入继续由 NocoBase ACL、字段权限和行级权限裁决;不需要共享账号或远程 MCP 服务。 - -## 用户侧依赖 - -正式 Release 是一个已打包的 npm tarball,内部包含 MCP SDK 和运行代码。用户电脑只需要: - -- Node.js 20.11 或更高版本(npm 随 Node.js 一起提供); -- 能访问固定的 NocoBase 环境 `https://justconn.top`; -- 用户自己的个人 Authorize Key。 - -用户不再需要 Git、Python、uv、Python 包或 `nb CLI`。安装也不会全局安装本项目,而是放到用户自己的应用数据目录。 - -Private GitHub 仓库的 Release 只能由已经获得仓库或分发渠道权限的用户下载。若要实现面向任意用户的“一条链接安装”,需要另行提供用户 Agent 能认证访问的下载地址。 +在你的电脑上,让 AI 客户端安全地查询 NocoBase CRM。安装时通过浏览器登录 NocoBase;每位用户使用自己的身份,看到的数据范围与本人在 NocoBase 中的权限一致。 ## 安装 -把对应 Release Tag 下的不可变 [`INSTALL.md`](INSTALL.md) 链接交给安装 Agent。该协议不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定客户端。Agent 会: +推荐把安装交给支持终端操作的 AI Agent 完成。你不需要克隆源码,也不需要逐条执行安装命令。 -1. 检查 Node.js,而不会静默安装系统工具; -2. 下载并校验固定 Release 的 npm tarball; -3. 安装到用户应用数据目录; -4. 引导用户在本机隐藏提示中输入自己的 Authorize Key; -5. 备份并结构化合并 MCP 客户端配置; -6. 调用 `crm_health` 验证完成。 +### 安装前准备 -默认 stdio、只读、不监听端口。只有用户明确要求且管理员确认 ACL 时,才给启动参数增加 `--enable-writes`,这只增加固定字段的 `create_project` 和 `update_project`,不提供删除或任意资源工具。 +- Node.js 20.11 或更高版本; +- 能访问 `https://justconn.top`; +- 你自己的 NocoBase 账号; +- 能读取当前 Gitea 仓库对应 Release 的权限。 + +如果 Node.js 尚未安装,安装 Agent 会先说明需要进行的系统变更,并在获得你确认后再继续。 + +不需要提前创建或复制 Authorize Key。安装程序会打开 NocoBase 官方网页;你在网页中登录并确认授权后,本机安装会自动继续。 + +### 最快安装方式 + +1. 打开当前 Gitea 仓库的 **Releases(版本发布)** 页面,选择要安装的固定版本。 +2. 确认该版本包含 npm 安装包和 `SHA256SUMS`,然后打开同一版本下的 `INSTALL.md`。不要使用默认分支中的安装说明代替固定版本。 +3. 把这个固定版本的 `INSTALL.md` 链接和下面的文字一起发给安装 Agent: + +```text +请按这个固定版本的安装协议帮我完成 NocoBase CRM MCP 本机安装。 +默认只读;请运行 setup,让我只在 NocoBase 官方网页完成登录和授权。 +不要向我索要密码、Token 或 Authorize Key,也不要静默安装系统工具。完成前请验证 crm_health 返回 ok=true。 +``` + +4. 浏览器打开后,在 `justconn.top` 登录并核对页面与终端显示的确认码,然后点击授权。浏览器没有自动打开时,复制终端显示的授权链接手动打开。 +5. 安装完成后,确认 Agent 报告 `crm_health` 返回 `ok=true`。 + +完整的安装和验收规则见 [INSTALL.md](INSTALL.md)。实际安装时应使用所选 Release Tag 中的同一文件。 + +## 重要注意事项 + +- **只在 NocoBase 网页登录:** 安装程序不会读取浏览器 Cookie,也不会接触你的密码;OAuth 令牌只保存到权限受限的个人凭据文件,不会进入聊天、命令参数、环境变量、CRM 配置、MCP 客户端配置或日志。 +- **默认只读:** 安装后默认只能查询 CRM,不会创建、更新或删除业务数据。 +- **权限跟随本人:** MCP 不会绕过 NocoBase 的角色、字段权限和行级权限,也不会使用共享管理员账号。 +- **只连接公司环境:** 程序固定连接 `https://justconn.top/api`,不会要求你填写其他 NocoBase 地址。 +- **安装在用户目录:** 不使用 `sudo`,不会全局安装本项目,也不需要 Git、Python、uv 或 `nb CLI`。 +- **受限仓库需要授权:** 如果 Agent 无法读取 Gitea Release,请联系仓库管理员开通权限,不要把访问令牌发到聊天中。 + +## 安装后可以做什么 + +你可以直接让 AI: + +- 查询客户、联系人和跟进记录; +- 按负责人、团队、状态或时间范围筛选 CRM 数据; +- 查看 CRM 总览、团队贡献和长期未跟进客户; +- 查看项目详情和项目跟进时间线。 + +例如: + +```text +列出我负责的、最近 30 天没有有效跟进的客户。 +``` + +```text +查看“华东团队”本月的客户、跟进和在途项目概况。 +``` + +## 可选写入 + +只有在你明确需要创建或更新项目,并且管理员已经确认你的 NocoBase 权限时,才应启用写入模式。 + +写入模式只增加固定字段的项目创建和更新能力,不提供删除操作,也不开放任意集合或任意字段写入。不要为了绕过权限错误而换用共享高权限 Key。 + +## 遇到问题 + +- 登录失效:重新运行安装程序的 `setup`,在浏览器中再次授权;正常情况下访问令牌会在后台自动刷新。 +- 浏览器没有打开:重新运行 `setup --no-browser`,然后手动打开终端显示的授权链接。 +- 仅在 OAuth 暂时不可用且管理员明确要求时,才使用 `setup --manual-key` 的隐藏输入备用模式;不要把 Key 发到聊天中。 +- 返回 `NO_PERMISSIONS`:请检查本人 NocoBase 角色和数据权限。 +- 请求超时:确认可以访问 `https://justconn.top`,或缩小查询范围。 +- Release 无法下载:确认当前账号或 Agent 已获得 Gitea 仓库和 Release 的读取权限。 + +更多故障处理和更新、回滚说明见 [本机交付与权限验收](docs/LOCAL_DELIVERY.md)。 + +## 更多文档 + +- [完整安装协议](INSTALL.md):供安装 Agent 执行的固定版本安装与验收流程。 +- [命令与工具参考](docs/REFERENCE.md):CLI 命令、MCP 工具和可选运行开关。 +- [内部架构](docs/ARCHITECTURE.md):模块边界、身份与网络安全设计。 +- [本机交付与权限验收](docs/LOCAL_DELIVERY.md):运维、权限验收、更新和回滚。 ## 本地开发 +开发者可从源码运行以下检查: + ```bash npm ci npm run check @@ -37,40 +98,4 @@ npm run build node dist/index.js --help ``` -开发时初始化个人配置: - -```bash -node dist/index.js setup --json -``` - -Authorize Key 只能输入到隐藏提示。不要把 Key 放进参数、环境变量、聊天、`config.json`、MCP 客户端配置或日志。 - -可用命令: - -```text -nocobase-crm-mcp setup [--config ] [--json] -nocobase-crm-mcp [serve] [--config ] [--enable-writes] -nocobase-crm-mcp doctor [--config ] [--json] -nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] -``` - -## 工具 - -默认提供 17 个只读工具: - -- `crm_health`, `crm_schema`, `crm_overview`, `crm_filter_options` -- `resolve_owner`, `list_teams`, `resolve_team`, `get_team`, `team_crm_overview` -- `list_customers`, `get_customer`, `list_contacts`, `list_followups` -- `list_projects`, `get_project`, `get_project_timeline`, `list_stale_customers` - -`NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 可临时增加 `crm_config_template`。`include_raw=true` 默认被拒绝;管理员审核后才可设置 `NOCOBASE_MCP_ENABLE_RAW_DATA=1`。 - -## 权限边界 - -```text -AI 客户端 → 本机 TypeScript MCP → HTTPS NocoBase API → 当前用户 Key 对应的 ACL -``` - -MCP 不缓存共享身份。每个请求都会从当前操作系统用户的私有凭证文件读取 Key,因此撤销或替换 Key 后无需重新生成 MCP 客户端配置。API 地址固定为 `https://justconn.top/api`,不接受安装参数或凭证文件覆盖;跨域重定向会在转发 Authorization 前被拒绝,错误内容会脱敏。 - -内部结构见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md),运维与权限验收见 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。 +本项目要求 Node.js 20.11 或更高版本。正式 Release 已将运行代码和依赖打包,最终用户不需要源码开发环境。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f9b5f8a..98dd2f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,7 +9,9 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien ## 模块边界 - `src/index.ts` 是单一可执行入口;`src/cli.ts` 管理 setup、doctor、客户端配置和 stdio 生命周期。 -- `src/credentials.ts` 固定 `https://justconn.top/api`、负责隐藏 TTY 输入和用户级私有凭证文件。凭证文件只保存 Key;Key 不进入 argv、环境变量、CRM 配置或 MCP 配置。 +- `src/nocobase-endpoints.ts` 集中固定 API、OAuth issuer、发现地址和 resource identifier。 +- `src/oauth.ts` 实现 OAuth 发现、动态公共客户端注册、设备授权、浏览器启动、轮询和刷新;不读取浏览器 Cookie,也不监听本机端口。 +- `src/credentials.ts` 管理用户级私有凭证文件与进程内缓存。默认保存 OAuth 访问/刷新令牌,兼容旧版个人 Key;任何凭据都不进入 argv、环境变量、CRM 配置或 MCP 配置。 - `src/nocobase-client.ts` 是唯一远程适配器,直接实现 NocoBase resource action 协议、data-modeling 读取、项目附件关系和 ACL 检查。 - `src/crm-service.ts` 负责 CRM 查询、解析、分页、聚合和可读输出;`src/crm-helpers.ts` 与 `src/filters.ts` 保存纯函数。 - `src/project-service.ts` 只允许固定项目字段的 create/update;`src/tools.ts` 是稳定 MCP 契约和错误/原始数据策略边界。 @@ -17,8 +19,10 @@ MCP client → TypeScript tool boundary → CRM domain service → NocoBaseClien ## 身份与网络安全 -- 每个请求重新读取当前 OS 用户的凭证文件,NocoBase ACL 是最终权限边界。 +- 凭据在进程内缓存,只有访问令牌临近过期时才进行一次并发合并的刷新;NocoBase ACL 是最终权限边界。 - 生产运行时只连接固定 HTTPS API `https://justconn.top/api`,不接受参数、配置或凭证文件覆盖。 +- OAuth issuer、协议端点和浏览器授权 URL 必须属于固定的 `https://justconn.top` 来源。 +- Device Authorization 请求 `api offline_access`;密码和浏览器会话不会离开 NocoBase。 - Authorization 只允许在同源重定向中保留,跨源重定向在发送凭证前失败。 - Token 会从 HTTP 错误中替换为 ``。 - stdio 是唯一默认传输,不创建后台服务或监听端口。 diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index 2163193..e1b0a1b 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -2,13 +2,13 @@ ## 交付模型 -每位用户在自己的电脑运行 MCP,并使用绑定本人业务身份的个人 Authorize Key: +每位用户在自己的电脑运行 MCP,并通过 NocoBase 原生 OAuth 登录本人业务身份: ```text 本机 AI 客户端 → 本机 TypeScript MCP → https://justconn.top/api → 本人 ACL ``` -项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。 +项目不保存共享业务账号,也不需要多人共用的远程 MCP 服务。个人 Authorize Key 仅作为显式备用认证方式。 ## Release 发布物 @@ -17,7 +17,7 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查 - npm tarball; - `SHA256SUMS`。 -源码仓库保持 Private 时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。Private GitHub 链接本身不能成为任意用户可访问的一键安装链接。 +Gitea 仓库访问受限时,外部用户必须具有 Release 下载权限,或通过另一个经过身份认证的分发渠道取得相同资产。受限仓库链接本身不能成为任意用户可访问的一键安装链接。 ## 用户电脑上的内容 @@ -33,24 +33,26 @@ Release workflow 会在带版本 Tag 时执行锁定安装、类型/格式检查 至少使用两个权限不同的真实测试账号: -1. 分别签发个人 Key,并在隔离的 OS 用户/凭证路径运行 setup。 +1. 分别使用两个账号在隔离的 OS 用户/凭证路径运行 setup,并在 NocoBase 网页授权。 2. 使用相同参数调用 `list_customers`、`list_contacts`、`list_followups`、`list_projects`。 3. 确认返回范围与各自在 NocoBase UI 中的 ACL 一致。 4. 无项目写权限账号在 `--enable-writes` 下调用 `create_project`,确认 NocoBase 拒绝。 5. 有权限账号创建和更新测试项目,确认审计身份属于该用户。 -6. 撤销 Key 后确认 MCP 返回 `nocobase_auth_failed`,且不会降级使用其他凭证。 -7. 检查错误与日志,确认没有 Key、Authorization header 或业务原始行泄漏。 +6. 让其中一个账号的 OAuth 会话失效,确认 MCP 自动刷新失败后返回 `nocobase_auth_failed`,且不会降级使用其他凭证。 +7. 检查错误与日志,确认没有访问令牌、刷新令牌、Authorization header、Key 或业务原始行泄漏。 ## 更新与回滚 Agent 下载新的固定 Release tarball并验证 SHA-256,再对同一隔离前缀运行 `npm install --prefix ... --omit=dev `。安装前备份 MCP 客户端配置和非秘密 CRM 配置。 -回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 Key。 +回滚时重新安装上一固定 Release 的已验证 tarball。凭证文件与 CRM 配置位于包目录之外,更新和回滚不应复制、覆盖或删除个人 OAuth 凭据。 ## 常见故障 -- `nocobase_auth_failed`:重新运行 setup,让用户在隐藏提示输入新签发 Key。 +- `nocobase_auth_failed`:重新运行 setup,让用户在 NocoBase 网页重新授权。 +- 浏览器无法启动:使用 `setup --no-browser` 并手动打开显示的授权链接。 +- OAuth 服务暂时不可用:先报告服务状态;只有管理员明确要求时才使用 `setup --manual-key`。 - `nocobase_request_timeout`:检查 NocoBase 可用性或缩小查询。 - `nocobase_request_failed` / `NO_PERMISSIONS`:检查该用户 ACL 和字段/行级权限。 - 凭证文件权限错误:POSIX 下修复为 `0600`,父目录为 `0700`,不要放宽为共享可读。 -- 跨源重定向错误:检查 `https://justconn.top/api` 的反向代理配置;不要关闭同源保护。 +- OAuth 来源校验错误:检查 `https://justconn.top/api` 的反向代理与发现元数据;不要放宽固定来源保护。 diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md new file mode 100644 index 0000000..76ae315 --- /dev/null +++ b/docs/REFERENCE.md @@ -0,0 +1,88 @@ +# 命令与工具参考 + +本文面向集成者和维护者。普通用户请从仓库根目录的 [README](../README.md) 开始,安装 Agent 请使用固定 Release Tag 中的 [INSTALL.md](../INSTALL.md)。 + +## CLI 命令 + +```text +nocobase-crm-mcp setup [--config ] [--no-browser] [--manual-key] [--json] +nocobase-crm-mcp [serve] [--config ] [--enable-writes] +nocobase-crm-mcp doctor [--config ] [--json] +nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] +``` + +- `setup`:通过 NocoBase OAuth 浏览器授权登录、准备 CRM 配置并输出 MCP 客户端配置。 +- `serve`:通过 stdio 启动 MCP;省略子命令时也是此模式。 +- `doctor`:验证凭据和 CRM 映射是否可用。 +- `client-config`:输出可合并到 MCP 客户端的配置片段,不包含任何 Token 或 Key。 +- `--enable-writes`:显式增加受控项目写入工具;默认不启用。 +- `--no-browser`:不自动启动浏览器,只显示 NocoBase 授权链接和确认码。 +- `--manual-key`:仅在 OAuth 不可用时,通过安全 TTY 隐藏输入个人 Authorize Key。 + +## 默认只读工具 + +默认稳定提供 17 个只读工具: + +- 健康、配置和总览:`crm_health`、`crm_schema`、`crm_overview`、`crm_filter_options` +- 用户与团队:`resolve_owner`、`list_teams`、`resolve_team`、`get_team`、`team_crm_overview` +- 客户与联系人:`list_customers`、`get_customer`、`list_contacts` +- 跟进与项目:`list_followups`、`list_projects`、`get_project`、`get_project_timeline` +- 客户维护:`list_stale_customers` + +查询结果默认返回经过整理的紧凑字段,而不是完整 NocoBase 原始记录。 + +## 可选工具 + +设置 `NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1` 时,会临时增加 `crm_config_template`,用于生成带字段元数据的配置参考。 + +使用 `--enable-writes` 启动时,只增加: + +- `create_project` +- `update_project` + +写入仅允许配置中定义的项目字段和附件关系,不提供删除、任意集合访问或任意字段写入。 + +## 原始数据策略 + +工具参数中的 `include_raw=true` 默认会被拒绝。只有经过管理员审核并显式设置以下变量后才允许返回原始行: + +```text +NOCOBASE_MCP_ENABLE_RAW_DATA=1 +``` + +不应把该变量作为普通用户的默认配置。 + +## 运行环境变量 + +- `NOCOBASE_MCP_CONFIG`:覆盖 CRM 映射配置文件路径。 +- `NOCOBASE_MCP_CREDENTIALS`:覆盖当前操作系统用户的凭据文件路径。 +- `NOCOBASE_MCP_DATA_SOURCE`:覆盖 NocoBase data source 名称。 +- `NOCOBASE_MCP_TIMEZONE`:覆盖结果显示时区。 +- `NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1`:启用配置模板工具。 +- `NOCOBASE_MCP_ENABLE_RAW_DATA=1`:经审核后允许请求原始数据。 + +OAuth Token 和 Authorize Key 都不支持也不允许通过环境变量传入。API 地址固定为 `https://justconn.top/api`,不能通过配置或环境变量覆盖。 + +## OAuth 登录 + +- 发现地址:`https://justconn.top/api/.well-known/openid-configuration` +- 授权模式:OAuth 2.1 Device Authorization +- 客户端类型:动态注册的 native public client,不保存 client secret +- 请求权限:`api offline_access` +- 浏览器只访问 `https://justconn.top`;程序不读取 Cookie 或浏览器存储 +- 访问令牌临近过期时自动刷新;并发请求只触发一次刷新 +- OAuth 端点、浏览器 URL 和 issuer 都必须匹配固定的 `justconn.top` 来源 + +## 默认运行边界 + +```text +AI 客户端 → 本机 stdio MCP → HTTPS NocoBase API → 当前用户 ACL +``` + +- 启动后凭据缓存在进程内存中;日常请求不重复读取文件,也不增加 OAuth 网络请求。 +- 只有访问令牌临近过期时才刷新,并把轮换后的凭据写回私有文件。 +- 默认不创建后台服务,不监听本机或公网端口。 +- NocoBase ACL、字段权限和行级权限是最终权限边界。 +- 认证失败、超时和上游请求错误会以结构化错误返回。 + +实现细节见 [内部架构](ARCHITECTURE.md),运维和真实权限验收见 [本机交付与权限验收](LOCAL_DELIVERY.md)。 diff --git a/install-manifest.json b/install-manifest.json index 08ceced..4476d1b 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -2,9 +2,9 @@ "schemaVersion": 2, "id": "com.nocobase.crm-mcp", "name": "NocoBase CRM MCP", - "version": "0.3.1", + "version": "0.4.0", "distribution": { - "repository": "https://github.com/Donaldttt/nocobase-mcp", + "repository": "https://git.justconn.top/admin/nocobase-mcp", "releaseRef": "must-match-v", "guide": "INSTALL.md from the exact Release tag", "assets": ["donaldttt-nocobase-crm-mcp-.tgz", "SHA256SUMS"], @@ -21,7 +21,9 @@ }, "nocobase": { "apiBaseUrl": "https://justconn.top/api", - "userConfigurableEndpoint": false + "userConfigurableEndpoint": false, + "oauthIssuer": "https://justconn.top/api", + "oauthResource": "https://justconn.top/api/" }, "installation": { "requiredTools": ["node", "npm"], @@ -36,8 +38,13 @@ "defaultBackgroundService": false }, "authentication": { - "type": "personal-authorize-key", - "secretEntry": "user-controlled-hidden-tty", + "type": "oauth-2.1-device-authorization", + "discovery": "https://justconn.top/api/.well-known/openid-configuration", + "scopes": ["api", "offline_access"], + "dynamicClientRegistration": true, + "browserSignIn": true, + "automaticRefresh": true, + "manualFallback": "user-controlled-hidden-tty-personal-authorize-key", "storage": "per-user private local credential file", "posixFileMode": "0600", "allowPassword": false, @@ -58,6 +65,7 @@ "sharedNocobaseCredential": false, "networkBind": "none-stdio", "remoteHttpRequiresTls": true, - "crossOriginAuthRedirect": false + "crossOriginAuthRedirect": false, + "browserCredentialRead": false } } diff --git a/package-lock.json b/package-lock.json index 2dbfecd..3a2afc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@donaldttt/nocobase-crm-mcp", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@donaldttt/nocobase-crm-mcp", - "version": "0.3.1", + "version": "0.4.0", "license": "UNLICENSED", "bin": { "nocobase-crm-mcp": "dist/index.js" diff --git a/package.json b/package.json index 5522335..591fcca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@donaldttt/nocobase-crm-mcp", - "version": "0.3.1", - "description": "Local NocoBase CRM MCP server using each user's personal Authorize Key", + "version": "0.4.0", + "description": "Local NocoBase CRM MCP server with per-user NocoBase OAuth browser sign-in", "type": "module", "private": true, "license": "UNLICENSED", diff --git a/src/cli.ts b/src/cli.ts index 209e1c6..4e3a64a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,17 @@ import { fileURLToPath } from "node:url"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { loadConfig } from "./config.js"; import { + createCredentialProvider, credentialsPath, - loadCredentials, + loadStoredCredentials, NOCOBASE_API_BASE_URL, readHidden, saveCredentials, + saveOAuthCredentials, } from "./credentials.js"; import { CrmService } from "./crm-service.js"; import { NocoBaseClient } from "./nocobase-client.js"; +import { loginWithBrowser } from "./oauth.js"; import { ProjectService } from "./project-service.js"; import { createServer } from "./tools.js"; import { VERSION } from "./version.js"; @@ -21,6 +24,8 @@ interface ParsedArgs { config?: string; enableWrites: boolean; json: boolean; + manualKey: boolean; + noBrowser: boolean; } export async function runCli(argv = process.argv.slice(2)): Promise { @@ -70,7 +75,7 @@ export function createRuntime(configPath: string): { projects: ProjectService; } { const config = loadConfig(configPath); - const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource }); + const client = new NocoBaseClient(createCredentialProvider(), { dataSource: config.dataSource }); return { client, crm: new CrmService(config, client), @@ -79,16 +84,48 @@ export function createRuntime(configPath: string): { } async function setup(args: ParsedArgs): Promise { - if (!process.stdin.isTTY || !process.stderr.isTTY) { - throw new Error("Setup requires a user-controlled local terminal; no files were changed"); + let authorizeKey: string; + let authentication: "oauth-browser" | "personal-authorize-key-fallback"; + if (args.manualKey) { + if (!process.stdin.isTTY || !process.stderr.isTTY) { + throw new Error("Manual Key setup requires a user-controlled local terminal; no files were changed"); + } + process.stderr.write( + `This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` + + "Enter a revocable personal Authorize Key only in the hidden prompt below.\n" + + "Do not paste the Key into Agent chat.\n", + ); + authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim(); + if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed"); + authentication = "personal-authorize-key-fallback"; + } else { + process.stderr.write( + `Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` + + "Your password and browser session stay inside NocoBase.\n", + ); + const oauthCredentials = await loginWithBrowser({ + clientId: reusableOAuthClientId(), + openBrowser: args.noBrowser ? async () => false : undefined, + onAuthorization: ({ opened, url, userCode, expiresIn }) => { + process.stderr.write( + `${opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${url}\n` + + `Confirmation code: ${userCode}\n` + + `Complete sign-in within ${Math.ceil(expiresIn / 60)} minutes. Waiting for approval...\n`, + ); + }, + }); + authorizeKey = oauthCredentials.accessToken; + authentication = "oauth-browser"; + const verificationClient = new NocoBaseClient(() => ({ + apiBaseUrl: NOCOBASE_API_BASE_URL, + authorizeKey, + })); + await verificationClient.verifyCredentials(); + const configPath = ensureConfig(args.config); + saveOAuthCredentials(oauthCredentials); + await printSetupResult(args, configPath, authentication); + return; } - process.stderr.write( - `This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` + - "Create a revocable personal Authorize Key in NocoBase for your own business account.\n" + - "Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n", - ); - const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim(); - if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed"); const verificationClient = new NocoBaseClient(() => ({ apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey, @@ -96,14 +133,26 @@ async function setup(args: ParsedArgs): Promise { await verificationClient.verifyCredentials(); const configPath = ensureConfig(args.config); saveCredentials({ authorizeKey }); + await printSetupResult(args, configPath, authentication); +} + +async function printSetupResult( + args: ParsedArgs, + configPath: string, + authentication: "oauth-browser" | "personal-authorize-key-fallback", +): Promise { const runtime = createRuntime(configPath); const health = await runtime.crm.crmHealth(); printResult( { ok: true, apiBaseUrl: NOCOBASE_API_BASE_URL, + authentication, credentialsPath: credentialsPath(), - keyExposure: "private-tty-and-0600-local-file-only", + credentialExposure: + authentication === "oauth-browser" + ? "nocobase-browser-and-0600-local-file-only" + : "private-tty-and-0600-local-file-only", configPath, mode: args.enableWrites ? "fixed-project-writes" : "read-only", clientConfig: clientConfig(configPath, args.enableWrites), @@ -113,6 +162,13 @@ async function setup(args: ParsedArgs): Promise { ); } +function reusableOAuthClientId(): string | undefined { + const path = credentialsPath(); + if (!existsSync(path)) return undefined; + const stored = loadStoredCredentials(path); + return stored.type === "oauth" ? stored.clientId : undefined; +} + function ensureConfig(explicit?: string): string { if (explicit) { const path = resolve(explicit); @@ -159,13 +215,21 @@ function parseArgs(argv: string[]): ParsedArgs { command = first as ParsedArgs["command"]; index = 1; } - const parsed: ParsedArgs = { command, enableWrites: false, json: false }; + const parsed: ParsedArgs = { + command, + enableWrites: false, + json: false, + manualKey: false, + noBrowser: false, + }; while (index < argv.length) { const flag = argv[index]; if (flag === "--help" || flag === "-h") parsed.command = "help"; else if (flag === "--version" || flag === "-v") parsed.command = "version"; else if (flag === "--enable-writes") parsed.enableWrites = true; else if (flag === "--json") parsed.json = true; + else if (flag === "--manual-key") parsed.manualKey = true; + else if (flag === "--no-browser") parsed.noBrowser = true; else if (flag === "--config") { const value = argv[index + 1]; if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`); @@ -174,6 +238,11 @@ function parseArgs(argv: string[]): ParsedArgs { } else throw new Error(`Unknown option: ${flag}`); index += 1; } + if (parsed.command !== "setup" && (parsed.manualKey || parsed.noBrowser)) { + throw new Error("--manual-key and --no-browser are available only with setup"); + } + if (parsed.manualKey && parsed.noBrowser) + throw new Error("--manual-key cannot be combined with --no-browser"); return parsed; } @@ -185,13 +254,15 @@ function helpText(): string { return `NocoBase CRM MCP ${VERSION} Usage: - nocobase-crm-mcp setup [--config ] [--json] + nocobase-crm-mcp setup [--config ] [--no-browser] [--manual-key] [--json] nocobase-crm-mcp [serve] [--config ] [--enable-writes] nocobase-crm-mcp doctor [--config ] [--json] nocobase-crm-mcp client-config [--config ] [--enable-writes] [--json] The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}. -The setup command accepts the personal Authorize Key only through a hidden local TTY prompt. +The setup command signs in through NocoBase OAuth in the user's browser. +Use --no-browser to print the authorization URL without opening it. +Use --manual-key only as a hidden local TTY fallback. The default stdio server is read-only. `; } diff --git a/src/credentials.ts b/src/credentials.ts index c3b55a1..cf1447b 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,6 +1,8 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { NOCOBASE_API_BASE_URL, NOCOBASE_OAUTH_ISSUER } from "./nocobase-endpoints.js"; +import { type OAuthCredentials, refreshOAuthCredentials } from "./oauth.js"; import { isRecord } from "./types.js"; export interface Credentials { @@ -8,7 +10,22 @@ export interface Credentials { authorizeKey: string; } -export const NOCOBASE_API_BASE_URL = "https://justconn.top/api"; +interface PersonalKeyCredentials { + type: "personal-authorize-key"; + authorizeKey: string; +} + +export type StoredCredentials = PersonalKeyCredentials | OAuthCredentials; + +export { NOCOBASE_API_BASE_URL }; + +export interface CredentialProviderOptions { + now?: () => number; + path?: string; + refresh?: (credentials: OAuthCredentials) => Promise; + refreshRetryDelayMs?: number; + refreshSkewMs?: number; +} export function credentialsPath(): string { const override = process.env.NOCOBASE_MCP_CREDENTIALS?.trim(); @@ -30,37 +47,88 @@ export function saveCredentials( ): void { const authorizeKey = credentials.authorizeKey.trim(); if (!authorizeKey) throw new Error("Authorize Key must not be blank"); - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - if (process.platform !== "win32") { - try { - const directoryMode = statSync(dirname(path)).mode & 0o777; - if ((directoryMode & 0o077) !== 0) - throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`); - } catch (error) { - if (existsSync(dirname(path))) throw error; - } - } - const descriptor = openSync(path, "w", 0o600); - try { - writeFileSync(descriptor, `${JSON.stringify({ authorizeKey }, null, 2)}\n`, "utf8"); - } finally { - closeSync(descriptor); - } + writePrivateCredentials(path, { authorizeKey }); +} + +export function saveOAuthCredentials(credentials: OAuthCredentials, path = credentialsPath()): void { + validateOAuthCredentials(credentials); + writePrivateCredentials(path, credentials); } export function loadCredentials(path = credentialsPath()): Credentials { + const stored = loadStoredCredentials(path); + return { + apiBaseUrl: NOCOBASE_API_BASE_URL, + authorizeKey: stored.type === "oauth" ? stored.accessToken : stored.authorizeKey, + }; +} + +export function loadStoredCredentials(path = credentialsPath()): StoredCredentials { if (process.platform !== "win32") { const mode = statSync(path).mode & 0o777; if ((mode & 0o077) !== 0) throw new Error(`Credential file must be private (0600); current mode is ${mode.toString(8)}`); } const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); - if (!isRecord(parsed) || typeof parsed.authorizeKey !== "string") { - throw new Error("Credential file is invalid; run setup again"); + if (!isRecord(parsed)) throw new Error("Credential file is invalid; run setup again"); + if (parsed.type === "oauth") { + const oauth = parsed as unknown as OAuthCredentials; + validateOAuthCredentials(oauth); + return { ...oauth }; } - const authorizeKey = parsed.authorizeKey.trim(); + const authorizeKey = typeof parsed.authorizeKey === "string" ? parsed.authorizeKey.trim() : ""; if (!authorizeKey) throw new Error("Credential file has no Authorize Key; run setup again"); - return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey }; + return { type: "personal-authorize-key", authorizeKey }; +} + +export function createCredentialProvider( + options: CredentialProviderOptions = {}, +): () => Promise { + const path = options.path ?? credentialsPath(); + const now = options.now ?? Date.now; + const refresh = options.refresh ?? ((credentials) => refreshOAuthCredentials(credentials)); + const refreshRetryDelayMs = options.refreshRetryDelayMs ?? 30_000; + const refreshSkewMs = options.refreshSkewMs ?? 60_000; + let stored: StoredCredentials | undefined; + let refreshInFlight: Promise | undefined; + let refreshRetryAt = 0; + + return async (): Promise => { + stored ??= loadStoredCredentials(path); + if (stored.type === "personal-authorize-key") { + return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.authorizeKey }; + } + const currentTime = now(); + if (stored.expiresAt > currentTime + refreshSkewMs) { + return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.accessToken }; + } + if (stored.expiresAt > currentTime && currentTime < refreshRetryAt) { + return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: stored.accessToken }; + } + const expiringCredentials = stored; + refreshInFlight ??= refresh(expiringCredentials) + .then((refreshed) => { + validateOAuthCredentials(refreshed); + saveOAuthCredentials(refreshed, path); + stored = refreshed; + refreshRetryAt = 0; + return refreshed; + }) + .finally(() => { + refreshInFlight = undefined; + }); + try { + const refreshed = await refreshInFlight; + return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: refreshed.accessToken }; + } catch (error) { + if (expiringCredentials.expiresAt > now()) { + refreshRetryAt = now() + refreshRetryDelayMs; + return { apiBaseUrl: NOCOBASE_API_BASE_URL, authorizeKey: expiringCredentials.accessToken }; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`OAuth session could not be refreshed; run setup again: ${message}`); + } + }; } export async function readHidden(prompt: string): Promise { @@ -98,3 +166,40 @@ export async function readHidden(prompt: string): Promise { process.stdin.on("data", onData); }); } + +function validateOAuthCredentials(credentials: OAuthCredentials): void { + if ( + credentials.type !== "oauth" || + credentials.issuer !== NOCOBASE_OAUTH_ISSUER || + typeof credentials.clientId !== "string" || + !credentials.clientId.trim() || + typeof credentials.accessToken !== "string" || + !credentials.accessToken.trim() || + typeof credentials.refreshToken !== "string" || + !credentials.refreshToken.trim() || + credentials.tokenType !== "Bearer" || + typeof credentials.scope !== "string" || + !credentials.scope.split(/\s+/).includes("api") || + typeof credentials.expiresAt !== "number" || + !Number.isFinite(credentials.expiresAt) || + credentials.expiresAt <= 0 + ) { + throw new Error("OAuth credential file is invalid; run setup again"); + } +} + +function writePrivateCredentials(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + const directoryMode = statSync(dirname(path)).mode & 0o777; + if ((directoryMode & 0o077) !== 0) + throw new Error(`Credential directory permissions are too broad: ${directoryMode.toString(8)}`); + } + const descriptor = openSync(path, "w", 0o600); + try { + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + } finally { + closeSync(descriptor); + } +} diff --git a/src/nocobase-endpoints.ts b/src/nocobase-endpoints.ts new file mode 100644 index 0000000..84f4184 --- /dev/null +++ b/src/nocobase-endpoints.ts @@ -0,0 +1,4 @@ +export const NOCOBASE_API_BASE_URL = "https://justconn.top/api"; +export const NOCOBASE_OAUTH_ISSUER = NOCOBASE_API_BASE_URL; +export const NOCOBASE_OAUTH_DISCOVERY_URL = `${NOCOBASE_OAUTH_ISSUER}/.well-known/openid-configuration`; +export const NOCOBASE_OAUTH_RESOURCE = `${NOCOBASE_API_BASE_URL}/`; diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..24766f6 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,433 @@ +import { spawn } from "node:child_process"; +import { + NOCOBASE_OAUTH_DISCOVERY_URL, + NOCOBASE_OAUTH_ISSUER, + NOCOBASE_OAUTH_RESOURCE, +} from "./nocobase-endpoints.js"; +import { isRecord } from "./types.js"; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const REQUESTED_SCOPE = "api offline_access"; +const REQUEST_TIMEOUT_MS = 15_000; + +export interface OAuthCredentials { + type: "oauth"; + issuer: string; + clientId: string; + accessToken: string; + refreshToken: string; + tokenType: "Bearer"; + scope: string; + expiresAt: number; +} + +interface OAuthDiscovery { + issuer: string; + deviceAuthorizationEndpoint: string; + registrationEndpoint: string; + tokenEndpoint: string; +} + +interface DeviceAuthorization { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresIn: number; + interval: number; +} + +export interface AuthorizationPrompt { + opened: boolean; + url: string; + userCode: string; + expiresIn: number; +} + +export interface BrowserLoginOptions { + clientId?: string; + fetch?: typeof fetch; + now?: () => number; + onAuthorization?: (prompt: AuthorizationPrompt) => void; + openBrowser?: (url: string) => Promise; + sleep?: (milliseconds: number) => Promise; +} + +export interface RefreshOptions { + fetch?: typeof fetch; + now?: () => number; +} + +export class OAuthProtocolError extends Error { + readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = "OAuthProtocolError"; + this.code = code; + } +} + +export async function loginWithBrowser(options: BrowserLoginOptions = {}): Promise { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? Date.now; + const sleep = options.sleep ?? defaultSleep; + const discovery = await discoverOAuth(fetchImpl); + let clientId = options.clientId?.trim(); + if (!clientId) clientId = await registerPublicClient(discovery, fetchImpl); + + let authorization: DeviceAuthorization; + try { + authorization = await startDeviceAuthorization(discovery, clientId, fetchImpl); + } catch (error) { + if (!(error instanceof OAuthProtocolError) || error.code !== "invalid_client" || !options.clientId) { + throw error; + } + clientId = await registerPublicClient(discovery, fetchImpl); + authorization = await startDeviceAuthorization(discovery, clientId, fetchImpl); + } + + const browserUrl = trustedBrowserUrl( + authorization.verificationUriComplete ?? authorization.verificationUri, + ).href; + const opened = await (options.openBrowser ?? openExternalUrl)(browserUrl); + options.onAuthorization?.({ + opened, + url: browserUrl, + userCode: authorization.userCode, + expiresIn: authorization.expiresIn, + }); + + return await pollForTokens(discovery, clientId, authorization, { + fetch: fetchImpl, + now, + sleep, + }); +} + +export async function refreshOAuthCredentials( + credentials: OAuthCredentials, + options: RefreshOptions = {}, +): Promise { + if (credentials.issuer !== NOCOBASE_OAUTH_ISSUER) { + throw new OAuthProtocolError("Stored OAuth issuer is not the configured NocoBase instance"); + } + const fetchImpl = options.fetch ?? fetch; + const discovery = await discoverOAuth(fetchImpl); + const form = new URLSearchParams({ + grant_type: "refresh_token", + client_id: credentials.clientId, + refresh_token: credentials.refreshToken, + resource: NOCOBASE_OAUTH_RESOURCE, + }); + const payload = await requestJson( + discovery.tokenEndpoint, + { + method: "POST", + headers: formHeaders(), + body: form, + }, + fetchImpl, + [credentials.refreshToken, credentials.accessToken], + ); + return tokenCredentials(payload, credentials.clientId, options.now ?? Date.now, { + refreshToken: credentials.refreshToken, + scope: credentials.scope, + }); +} + +export async function openExternalUrl(url: string): Promise { + const target = trustedBrowserUrl(url).href; + const command = browserCommand(target); + return await new Promise((resolveValue) => { + let settled = false; + const settle = (value: boolean): void => { + if (settled) return; + settled = true; + resolveValue(value); + }; + try { + const child = spawn(command.executable, command.args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.once("error", () => settle(false)); + child.once("spawn", () => { + child.unref(); + settle(true); + }); + } catch { + settle(false); + } + }); +} + +async function discoverOAuth(fetchImpl: typeof fetch): Promise { + const payload = await requestJson( + NOCOBASE_OAUTH_DISCOVERY_URL, + { method: "GET", headers: { accept: "application/json" } }, + fetchImpl, + ); + if (!isRecord(payload) || payload.issuer !== NOCOBASE_OAUTH_ISSUER) { + throw new OAuthProtocolError("NocoBase OAuth discovery returned an unexpected issuer"); + } + requireArrayValue(payload, "grant_types_supported", DEVICE_GRANT); + requireArrayValue(payload, "grant_types_supported", "refresh_token"); + requireArrayValue(payload, "scopes_supported", "api"); + requireArrayValue(payload, "scopes_supported", "offline_access"); + requireArrayValue(payload, "token_endpoint_auth_methods_supported", "none"); + return { + issuer: payload.issuer, + deviceAuthorizationEndpoint: trustedApiEndpoint(stringField(payload, "device_authorization_endpoint")) + .href, + registrationEndpoint: trustedApiEndpoint(stringField(payload, "registration_endpoint")).href, + tokenEndpoint: trustedApiEndpoint(stringField(payload, "token_endpoint")).href, + }; +} + +async function registerPublicClient(discovery: OAuthDiscovery, fetchImpl: typeof fetch): Promise { + const payload = await requestJson( + discovery.registrationEndpoint, + { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "NocoBase CRM MCP", + application_type: "native", + grant_types: [DEVICE_GRANT, "refresh_token"], + response_types: [], + redirect_uris: [], + token_endpoint_auth_method: "none", + }), + }, + fetchImpl, + ); + if (!isRecord(payload)) throw new OAuthProtocolError("OAuth client registration returned invalid JSON"); + const clientId = stringField(payload, "client_id").trim(); + if (!clientId) throw new OAuthProtocolError("OAuth client registration returned no client ID"); + if (typeof payload.client_secret === "string" && payload.client_secret) { + throw new OAuthProtocolError("NocoBase registered an unexpected confidential OAuth client"); + } + return clientId; +} + +async function startDeviceAuthorization( + discovery: OAuthDiscovery, + clientId: string, + fetchImpl: typeof fetch, +): Promise { + const form = new URLSearchParams({ + client_id: clientId, + scope: REQUESTED_SCOPE, + resource: NOCOBASE_OAUTH_RESOURCE, + }); + const payload = await requestJson( + discovery.deviceAuthorizationEndpoint, + { method: "POST", headers: formHeaders(), body: form }, + fetchImpl, + ); + if (!isRecord(payload)) throw new OAuthProtocolError("Device authorization returned invalid JSON"); + const expiresIn = positiveNumber(payload.expires_in, "expires_in"); + const intervalValue = payload.interval === undefined ? 5 : positiveNumber(payload.interval, "interval"); + return { + deviceCode: stringField(payload, "device_code"), + userCode: stringField(payload, "user_code"), + verificationUri: trustedBrowserUrl(stringField(payload, "verification_uri")).href, + verificationUriComplete: + typeof payload.verification_uri_complete === "string" + ? trustedBrowserUrl(payload.verification_uri_complete).href + : undefined, + expiresIn, + interval: Math.min(Math.max(intervalValue, 1), 30), + }; +} + +async function pollForTokens( + discovery: OAuthDiscovery, + clientId: string, + authorization: DeviceAuthorization, + options: Required>, +): Promise { + const deadline = options.now() + authorization.expiresIn * 1_000; + let intervalSeconds = authorization.interval; + while (options.now() < deadline) { + await options.sleep(intervalSeconds * 1_000); + const form = new URLSearchParams({ + grant_type: DEVICE_GRANT, + client_id: clientId, + device_code: authorization.deviceCode, + resource: NOCOBASE_OAUTH_RESOURCE, + }); + try { + const payload = await requestJson( + discovery.tokenEndpoint, + { method: "POST", headers: formHeaders(), body: form }, + options.fetch, + [authorization.deviceCode], + ); + return tokenCredentials(payload, clientId, options.now, { requireRefreshToken: true }); + } catch (error) { + if (error instanceof OAuthProtocolError && error.code === "authorization_pending") continue; + if (error instanceof OAuthProtocolError && error.code === "slow_down") { + intervalSeconds = Math.min(intervalSeconds + 5, 30); + continue; + } + if (error instanceof OAuthProtocolError && error.code === "access_denied") { + throw new OAuthProtocolError("NocoBase browser authorization was cancelled", error.code); + } + if (error instanceof OAuthProtocolError && error.code === "expired_token") break; + throw error; + } + } + throw new OAuthProtocolError("NocoBase browser authorization expired; run setup again", "expired_token"); +} + +function tokenCredentials( + payload: unknown, + clientId: string, + now: () => number, + fallback: { refreshToken?: string; scope?: string; requireRefreshToken?: boolean }, +): OAuthCredentials { + if (!isRecord(payload)) throw new OAuthProtocolError("OAuth token endpoint returned invalid JSON"); + const accessToken = stringField(payload, "access_token"); + const tokenType = stringField(payload, "token_type"); + if (tokenType.toLowerCase() !== "bearer") { + throw new OAuthProtocolError("NocoBase returned an unsupported OAuth token type"); + } + const refreshToken = + typeof payload.refresh_token === "string" && payload.refresh_token.trim() + ? payload.refresh_token.trim() + : fallback.refreshToken; + if (!refreshToken && fallback.requireRefreshToken) { + throw new OAuthProtocolError("NocoBase did not issue an offline refresh token"); + } + if (!refreshToken) throw new OAuthProtocolError("Stored OAuth session has no refresh token"); + const scope = + typeof payload.scope === "string" && payload.scope.trim() ? payload.scope.trim() : (fallback.scope ?? ""); + if (!scope.split(/\s+/).includes("api")) { + throw new OAuthProtocolError("NocoBase did not grant the required API scope"); + } + return { + type: "oauth", + issuer: NOCOBASE_OAUTH_ISSUER, + clientId, + accessToken, + refreshToken, + tokenType: "Bearer", + scope, + expiresAt: now() + positiveNumber(payload.expires_in, "expires_in") * 1_000, + }; +} + +async function requestJson( + url: string, + init: RequestInit, + fetchImpl: typeof fetch, + secrets: string[] = [], +): Promise { + const endpoint = trustedApiEndpoint(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + timeout.unref?.(); + try { + const response = await fetchImpl(endpoint, { + ...init, + redirect: "error", + signal: controller.signal, + }); + const text = await response.text(); + let payload: unknown = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch { + throw new OAuthProtocolError("NocoBase OAuth endpoint returned invalid JSON"); + } + } + if (!response.ok) { + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : undefined; + const description = + isRecord(payload) && typeof payload.error_description === "string" + ? redact(payload.error_description, secrets) + : undefined; + throw new OAuthProtocolError( + description || code || `NocoBase OAuth request failed with HTTP ${response.status}`, + code, + ); + } + return payload; + } catch (error) { + if (error instanceof OAuthProtocolError) throw error; + if (error instanceof Error && error.name === "AbortError") { + throw new OAuthProtocolError("NocoBase OAuth request timed out"); + } + const message = error instanceof Error ? error.message : String(error); + throw new OAuthProtocolError(`NocoBase OAuth request failed: ${redact(message, secrets)}`); + } finally { + clearTimeout(timeout); + } +} + +function trustedApiEndpoint(value: string | URL): URL { + const url = new URL(value); + const expected = new URL(NOCOBASE_OAUTH_ISSUER); + if (url.protocol !== "https:" || url.origin !== expected.origin || !url.pathname.startsWith("/api/")) { + throw new OAuthProtocolError("NocoBase OAuth metadata referenced an untrusted endpoint"); + } + return url; +} + +function trustedBrowserUrl(value: string): URL { + const url = new URL(value); + const expected = new URL(NOCOBASE_OAUTH_ISSUER); + if (url.protocol !== "https:" || url.origin !== expected.origin) { + throw new OAuthProtocolError("NocoBase OAuth returned an untrusted browser URL"); + } + return url; +} + +function formHeaders(): Record { + return { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }; +} + +function stringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || !field.trim()) { + throw new OAuthProtocolError(`NocoBase OAuth response has no ${key}`); + } + return field.trim(); +} + +function positiveNumber(value: unknown, key: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new OAuthProtocolError(`NocoBase OAuth response has invalid ${key}`); + } + return value; +} + +function requireArrayValue(payload: Record, key: string, expected: string): void { + const values = payload[key]; + if (!Array.isArray(values) || !values.includes(expected)) { + throw new OAuthProtocolError(`NocoBase OAuth server does not support ${expected}`); + } +} + +function redact(message: string, secrets: string[]): string { + return secrets.filter(Boolean).reduce((result, secret) => result.replaceAll(secret, ""), message); +} + +function browserCommand(url: string): { executable: string; args: string[] } { + if (process.platform === "darwin") return { executable: "open", args: [url] }; + if (process.platform === "win32") { + return { executable: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] }; + } + return { executable: "xdg-open", args: [url] }; +} + +async function defaultSleep(milliseconds: number): Promise { + await new Promise((resolveValue) => setTimeout(resolveValue, milliseconds)); +} diff --git a/src/tools.ts b/src/tools.ts index 7ce9fa6..da543c8 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -26,7 +26,7 @@ export function createServer(options: { register( server, "crm_health", - "Check NocoBase reachability, personal Key authentication, and CRM mapping.", + "Check NocoBase reachability, user authentication, and CRM mapping.", {}, () => crm.crmHealth(), ); @@ -413,7 +413,7 @@ async function safeTool(operation: () => Promise): Promise