☰
OpenMausBot 远程 MCP 登录验证实战:用伪造 OAuth 提供方跑通另一台电脑上的 MCP 授权
2026/10/31 10:43:45 网站建设 项目流程
  • 人工智能
  • AI Agent
  • AI 应用
  • 大模型
  • 交互助手
  • 工具调用
  • Agent 编排
  • 桌面应用

【免费下载链接】OpenMausBot

Open-source Grok Bot alternative with a virtual machine that bots can use

项目地址:https://gitcode.com/GitHub_Trending/op/OpenMausBot
点击查看免费下载

本文围绕 OpenMausBot 的 MCP(Model Context Protocol)OAuth 登录能力展开,重点讲解如何在不接触用户正在运行的应用、不读取真实 OAuth 凭据、不登录任何真实服务的前提下,验证"从另一台电脑完成 MCP 登录"这条链路。读完后你将掌握:如何按文档给出的命令分层运行单元测试与 API 测试、如何用verify-mcp-sign-in.ts夹具驱动真实设置面板、以及这条链路背后 PKCE 交换、回调校验、粘贴回填、取消与登出的实现原理。

为什么需要"从另一台电脑登录"

MCP 服务器通常以 OAuth 授权码模式接入。OpenMausBot 的默认做法是最标准的本地回环:服务端在127.0.0.1上起一个临时监听,浏览器授权后被重定向回http://127.0.0.1:<port>/mcp-oauth/callback,回调直接落到进程内。

但当管理界面是从另一台电脑(或者经过反向代理的 Cloud / 托管工作区)打开的,这个回环地址对那台电脑毫无意义——浏览器会把用户带到一个打不开的127.0.0.1页面。OpenMausBot 的处理方式有两条:

  1. 能证明来源时:为当前 https 地址动态注册客户端,回调直接指向https://<你的地址>/mcp-oauth/callback,由服务端公开回调接口接管,完全不在本机开监听;
  2. 无法证明来源时:仍然走回环监听,但把状态标记为pasteBack,界面上直接给出"粘贴回填"表单,让用户把浏览器地址栏里那串http://127.0.0.1:.../mcp-oauth/callback?...整段复制回来。

docs/verification/mcp-sign-in.md描述的正是这条链路的验证方法,也是本文的主干。

第一层:聚焦单元测试与 API 测试

文档给出的第一条命令把 6 个测试文件一次性跑完:

pnpm exec vitest run server/mcp-oauth.test.ts server/mcp-oauth-discovery.test.ts server/mcp-oauth-store.test.ts server/mcp-probe.test.ts src/lib/mcp-sign-in.test.ts src/components/McpServersPanel.test.ts

第二条命令只挑server/index.test.ts里与 MCP OAuth 相关的用例,避免整套服务端测试的时间成本:

pnpm exec vitest run server/index.test.ts -t 'MCP OAuth|OAuth URL MCP'

两组命令覆盖的职责边界很清晰,按下表理解即可:

测试文件关注点
server/mcp-oauth.test.ts授权码流程、PKCE、状态校验、过期、取消、token 刷新与登出、远程/粘贴两种完成方式
server/mcp-oauth-discovery.test.tsRFC 8414 / RFC 9728 元数据发现、resource_metadata解析、issuer 一致性
server/mcp-oauth-store.test.ts独立凭据文件的读写、schema 校验、按 URL 绑定
server/mcp-probe.test.ts探测 MCP 服务器:auth: "required"的产生与工具列表返回
src/lib/mcp-sign-in.test.ts前端登录驱动:仅打开 https 链接、轮询、取消、粘贴提交
src/components/McpServersPanel.test.ts设置面板行为:按钮状态、错误显示、登录后自动测试并启用

测试到底验证了哪些攻击面

server/mcp-oauth.test.ts里的 "MCP sign-in from another computer" 场景是重头戏。它用startFakeOAuth+startFakeHttpMcp起一套本地假服务,然后逐条断言:

  • 所有者隔离:alice启动的流程,bob去start会得到 409,status返回undefined,cancelFlow抛错,completeCallback得到 404,且oauth.counts.token保持为 0;
  • 回放防护:同一个回调地址第二次提交得到 409,并且 token 端点只被调用一次;
  • 畸形回调:not a URL、长度 16385 的超长输入、被替换成attacker.example的主机、http:改https:、混入user:pass@、换路径、追加#fragment、重复code、code与error并存、错误的iss、错误的state、错误的端口——全部被拒,且流程不被消耗、不会对用户提供的地址发起请求;
  • 过期与取消:无人完成的登录到点变成expired并关闭监听;注册阶段或授权码交换阶段被取消时,token 不会被写入。
const variants = [ "not a URL", "x".repeat(16_385), callback.replace("127.0.0.1", "attacker.example"), callback.replace("http:", "https:"), callback.replace("127.0.0.1", "user:pass@127.0.0.1"), callback.replace("/mcp-oauth/callback", "/other"), callback + "#fragment", callback + "&code=duplicate", callback + "&error=access_denied", callback + "&iss=https%3A%2F%2Fwrong.example", ];

这些变体对应的实现是 server/mcp-oauth.ts 中的acceptCallback:它先比对origin与pathname,再拒绝username/password/hash,要求所有查询参数都是单值(params.getAll(key).length === 1),随后用恒定时间比较state(sameSecret,见 server/mcp-oauth.ts),校验可选iss,最后要求code与error互斥且code不超过 4096 字节。任何一条不满足就直接 400,且不会把flow.consumed置真——这正是"无效 URL 不消耗待完成流程"的底层原因。

第二层:真实面板 + 伪造引擎工作区的端到端夹具

单元测试证明的是函数级契约,文档要求再跑一次真实界面:

node --experimental-strip-types scripts/verify-mcp-sign-in.ts

脚本开头三行就交代了隔离边界(scripts/verify-mcp-sign-in.ts):

// Real MCP panel + server + fake OAuth provider, only in a disposable workspace. const fixture = await launchVerificationServer(); let oauth: Awaited<ReturnType<typeof startFakeOAuth>> | undefined; let mcp: Awaited<ReturnType<typeof startFakeHttpMcp>> | undefined;

launchVerificationServer来自 scripts/control-omb.ts,它会:

  • 用mkdtempSync建一个临时数据目录,并把HOME、USERPROFILE、XDG_*、TMPDIR、OMB_DATA_DIR全部指向它;
  • 选一对空闲端口(OMB_PORT与OMB_WEBHOOK_PORT),写入config.json,只装仓库自带的 fake engine(server/testing/fake-claude-cli.ts);
  • 设置OMB_TEST_SEALED_PATH=1,避免扫描到开发者本机真实引擎 CLI;
  • 轮询/api/health直到返回app: "openmausbot",并断言只播种了一个 starter bot,然后重命名为Fixture Starter。

夹具随后做的事,与文档描述完全一致:

oauth = await startFakeOAuth({ beforeToken: () => tokenPending }); mcp = await startFakeHttpMcp({ acceptBearer: oauth.isValid, wwwAuthenticate: oauth.challenge }); const api = fixtureApi(fixture.info.url); await runControlOmb(["doctor", "--url", fixture.info.url]); await api("POST", "/api/mcp/servers", { name: "documents", url: mcp.url }); await api("POST", "/api/mcp/servers/documents/test"); const pairing = await api("POST", "/api/auth/pairing", { scopes: ["admin", "client"] });

也就是说:doctor打到这台临时服务器、新建一个名为documents的合成 MCP 服务器、配对一个具备admin与client双 scope 的浏览器会话。整个过程不会碰用户正在跑的 OpenMausBot,也不会读他们的 OAuth 凭据。

pasteBack是如何被"逼"出来的

夹具最巧妙的一处,是它同时要满足两个互相拉扯的约束:真实的伪造 OAuth 提供方说的是 loopback HTTP,而渲染层只允许打开 https 链接(mcpSignInLink只认protocol === "https:"且无用户名密码,见 src/lib/mcp-sign-in.ts)。

解决办法是一条仅存在于夹具中的代理路由:它把真实开始响应里的authorizationUrl换成https://oauth.example.test/authorize,但不改其它字段。

extraRoutes: [{ path: "/api/mcp/servers/documents/sign-in", method: "POST", handler: async (req, res) => { const response = await fetch(`${fixture.info.url}/api/mcp/servers/documents/sign-in`, { method: "POST", headers: { cookie: req.headers.cookie ?? "", "x-forwarded-for": "192.0.2.10" }, }); const body = await response.json(); if (response.ok) { authorizationUrl = body.auth.authorizationUrl; body.auth.authorizationUrl = "https://oauth.example.test/authorize"; } res.writeHead(response.status, { "content-type": "application/json" }).end(JSON.stringify(body)); }, }]

注意请求头:x-forwarded-for会命中服务端"这是远程浏览器"的判断(server/routes/mcp-servers.ts 中remote = auth.kind === "session" && (isProxied(req) || !isLoopbackHost(req.headers.host))),因此服务端不会给这个流程发pasteBack;又因为来自代理的请求带不出可证明的 https origin,mcpCallbackOrigin返回null,于是它退回回环监听模式。夹具随后故意执行window.open = () => null模拟弹窗被拦截——这正好触发面板里"从另一台电脑登录"的<details>展开区与粘贴表单。

夹具验证的 8 条行为

文档列出的清单,逐条都能在脚本里找到对应断言:

文档中的验证点脚本中的实现
代理管理会话能启动流程上面的extraRoutes转发POST .../sign-in
弹窗被拦截仍保留重开按钮与粘贴说明window.open = () => null后等待After you approve, your browser shows a page that can与#mcp-callback-documents出现
无效 URL 报错且不消耗流程填入http://127.0.0.1:1/wrong?code=wrong&state=wrong,等待[role=alert],并断言oauth.counts.token === 0
延迟的 token 交换保持按钮禁用且旋转、保留已粘贴 URL、提供方返回后展示结果beforeToken: () => tokenPending挂起 token 端点,断言button[aria-busy=true]的disabled为true、输入框value仍是原 URL
交换期间取消后立即可重新登录,且粘贴表单在新请求落定前可用点Cancel→ 点Sign in→ 重新填表 → 再点Complete sign-in
粘贴伪造提供方的重定向地址能完成真实 PKCE 交换用fetch(authorizationUrl, { redirect: "manual" })取出location填入,最终oauth.counts.token === 2
已登录的 MCP 服务器在 Test 时返回工具等待Connected: 1 tool
登出取消下一个待完成流程并关闭其监听登出后重新开始,/api/auth/logout后对location的fetch必须 reject

几处细节值得展开:

为什么不跟随重定向:脚本用redirect: "manual"拿到伪造提供方 302 的location,再把这串 URL 粘贴进真实 UI。这样既没有让浏览器真的访问回环监听,也证明了"粘贴回填"链路本身可用——这正是文档强调的"证明的是无头完成流程,而不是与某个真实 OAuth 提供方的兼容性"。

const approved = await fetch(authorizationUrl, { redirect: "manual" }); const callbackUrl = approved.headers.get("location")!; await command("fill", "#mcp-callback-documents", callbackUrl); await clickButton("Complete sign-in");

延迟交换的两个断言:aria-busy=true且带.animate-spin、disabled === true、输入框值不变,三者同时成立才算通过。对应的 UI 在 src/components/McpServersPanel.tsx:输入框disabled={completingSignIn},按钮aria-busy={completingSignIn},取消按钮只在signingIn时出现。

取消与旧请求的竞态:signInAbort.current === controller这一守卫(src/components/McpServersPanel.tsx)保证旧请求落定时不会污染新表单的忙碌状态;服务端侧则用held的tokenPending复现"旧请求尚未落定"的窗口。对应单元测试是does not let an old cancellation cancel a new attempt。

登出必须真的让回调失效:脚本断言登出后对先前location的fetch会 reject。这依赖signOut先cancel(name)关闭监听再删记录(server/mcp-oauth.ts),以及回调响应头里的Connection: close(server/mcp-oauth.ts)——否则一条 keep-alive 连接会把下一次登录的回调送到已经停止接收的监听上。

环境变量与浏览器准备

文档给出的复用方式与默认值:

# 复用已安装的 agent-browser 与 Chrome export OMB_AGENT_BROWSER_PATH=/path/to/agent-browser export AGENT_BROWSER_EXECUTABLE_PATH=/path/to/chrome # 自定义截图证据目录(默认 .omb-scratch/mcp-oauth-evidence) export OMB_UI_EVIDENCE_DIR=/tmp/mcp-evidence

ensureUiBrowser的解析顺序是OMB_AGENT_BROWSER_PATH→ 工具目录 →PATH,最后才下载固定版本;Chrome 同理走AGENT_BROWSER_EXECUTABLE_PATH,两者都落在同一个UI_TOOLS_DIR下。若显式设置了AGENT_BROWSER_EXECUTABLE_PATH但文件不存在,会直接报错并提示 "unset it to use the Chrome agent-browser installs"(scripts/testing/control-omb-ui.ts)。

夹具自身还给浏览器进程注入了严格环境(scripts/verify-mcp-sign-in.ts):HOME/USERPROFILE指向临时数据目录、TMPDIR指到其下的browser-tmp、AGENT_BROWSER_SESSION带上 PID 避免串会话、AGENT_BROWSER_HEADLESS=1、AGENT_BROWSER_NO_WEBMCP=1。这些是"绝不接触用户正在运行的应用"的具体落地方式。

脚本成功后输出一行 JSON,把验证过的项目显式列出,便于 CI 断言:

{"ok":true,"evidence":"/abs/path/.omb-scratch/mcp-oauth-evidence", "tested":["remote admin start","blocked popup","invalid URL retry","pending spinner and preserved input", "cancel and retry during pending completion","paste completion","MCP tools","logout cancellation"]}

服务端实现:一次登录的完整生命周期

阶段一:发现授权元数据

discoverMcpAuth先向 MCP 服务器发一次initialize,若返回 401 就取WWW-Authenticate里的resource_metadata(challenge()见 server/mcp-oauth.ts),再按顺序尝试/.well-known/oauth-protected-resource<path>与/.well-known/oauth-protected-resource;拿到authorization_servers[0]作为 issuer 后,再试/.well-known/oauth-authorization-server、/.well-known/openid-configuration。

server/mcp-oauth-discovery.ts 里有几条硬性约束:isAllowedAuthUrl只放行 https 与 loopback http;所有抓取都带redirect: "error"(不跟随重定向)、5 秒超时、64 KiB 上限;必须声明支持S256;元数据里的issuer必须与抓取时使用的 issuer 一致(RFC 8414 §3.3)。最终把 MCP URL 本身作为 RFC 8707 的resource返回,使 token 具备受众绑定。

阶段二:注册客户端或复用预注册应用

如果用户在服务器条目上配置了oauth(clientId,可选clientSecret、scopes),就走"预注册应用"路径;否则尝试动态注册。注册时请求体固定为公共客户端:

client_name: "OpenMausBot", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none",

若元数据里没有registration_endpoint,抛McpOAuthError("no-registration", ...),并在提示里直接教用户"编辑服务器,在 Sign-in app 下填该应用的 client ID"——这是常见的一类失败。

回环端口不是随机的,而是对 URL 做 FNV-1a 哈希后映射到20000 + hash % 20000(server/mcp-oauth.ts)。这样做是为了让预注册应用每次回到同一个redirect_uri。因此当端口被占用且存在预注册客户端时,服务端不会退让到随机端口,而是报port-busy并提示"关闭占用该端口的程序"(对应测试explains a busy redirect port instead of moving to another one);只有动态注册场景才会listen(server, 0)。

阶段三:拼装授权 URL

authorize.searchParams.set("response_type", "code"); authorize.searchParams.set("client_id", client.id); authorize.searchParams.set("redirect_uri", redirectUri); authorize.searchParams.set("state", state); // randomBytes(32) base64url authorize.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url")); authorize.searchParams.set("code_challenge_method", "S256"); authorize.searchParams.set("resource", meta.resource); if (scopes?.length) authorize.searchParams.set("scope", scopes.join(" "));

state与verifier各 32 字节,flowId为randomUUID(),默认有效期 5 分钟(DEFAULT_LIFETIME_MS),到期定时器unref()以免拖住进程退出。此处还决定pasteBack:

...(server && starting.browser.remote ? { pasteBack: true } : {}),

即"存在本地监听 + 浏览器在远程"这一组合才需要粘贴回填。

阶段四:两种回收路径

回环监听回收:server.on("request", ...)落到callback(),先校验Host必须等于redirectUri的 host、URL 必须以/开头,然后交给answer();响应固定Cache-Control: no-store、Referrer-Policy: no-referrer、Connection: close,正文只使用固定话术("Signed in. You can close this tab and return to OpenMausBot." / "Invalid or expired sign-in..."),绝不回显code或提供方自己的错误文本。

公开回调回收:publicCallback(host, search)遍历所有流程,要求该流程没有本地监听、host 匹配、且state恒定时间相等,然后调用同一个acceptCallback。所以远程 https 回调与本地监听共享全部校验逻辑,包括iss比对(RFC 9207,允许 Google 那种 issuer 尾部斜杠差异)。

粘贴回收:completeCallback先在本地解析 URL(长度上限 16384),再走同一套校验,绝不向用户提供的地址发起请求。

阶段五:兑换、落盘与善后

授权码兑换固定走表单 POST,形参包含grant_type=authorization_code、code、redirect_uri、code_verifier、resource,10 秒超时。若流程在兑换期间被取消或条目被移除,服务端不会复活登录,而是把刚拿到的 token 送去revocationEndpoint,并返回内部标记DISCARDED,最终对调用方表现为 409:

if (this.flows.get(flow.name) !== flow || flow.status.phase !== "waiting") { if (flow.meta.revocationEndpoint) { void postForm(flow.meta.revocationEndpoint, { token: tokens.refresh ?? tokens.access }, flow.client).catch(() => undefined); } return DISCARDED; }

成功时写入的记录包含issuer、clientId、可选的tokenAuth、redirectUri、tokenEndpoint、revocationEndpoint与tokens。tokenAuth依据 RFC 8414 §2 推导:没有 secret 即none;有 secret 且元数据未声明或声明支持client_secret_basic,默认用 Basic,并按 RFC 6749 §2.3.1 先做表单编码;仅当服务器只接受client_secret_post时才把 secret 放进表单。

凭据存放:为什么 token 不在 config.json 里

server/mcp-oauth-store.ts 的第一行注释就把设计意图写死了:与config.json分开存放,这样粘贴或导出配置时不会带上 token;并且 token 永远不会跟着条目迁移到别的 URL。

get(name, url)会校验记录里的url与请求一致,因此把一个服务器的登录记录改指向另一个地址时,旧 token 自动失效(对应测试never hands a token to a different URL)。落盘用writeFileAtomic(..., { mode: 0o600 }),读取则是"每次都重读",因为另一个进程可能刚改过这个文件。

访问 token 的取用由accessToken统一把关:距过期不足 2 分钟(REFRESH_MARGIN_MS)就刷新,并发调用共享同一个Promise;刷新返回invalid_grant或invalid_client(或 400/401)才判定凭据已死并标记needs-sign-in,其它失败只要旧 token 仍在有效期内就继续使用。

最后一环是withMcpSignIn(server/mcp-oauth.ts):需要登录的服务器根本不会进入这一轮的服务器集合;已登录的会先剔除原有Authorization头再注入Authorization: Bearer <token>。这样引擎侧只看到请求头,永远拿不到 token 本体。

路由与界面契约

服务端路由集中在 server/routes/mcp-servers.ts,共四个动作:

方法与路径作用关键约束
POST /api/mcp/servers/<name>/sign-in启动流程返回{ auth: McpSignInStatus };会话在启动后失效则撤销并 401
GET /api/mcp/servers/<name>/sign-in/<flowId>查询状态非本浏览器持有者返回 404
DELETE /api/mcp/servers/<name>/sign-in[/<flowId>]取消所有者不符抛 404
POST /api/mcp/servers/<name>/sign-in/<flowId>提交粘贴的回调必须application/json,正文上限 20480 字节
POST /api/mcp/servers/<name>/sign-out登出先撤销 token 再删记录

非 URL 服务器直接 400("Only servers reached by URL can be signed in to."),运行在别的电脑上的服务器 409(runsElsewhere)。所有者来自auth.kind === "session" ? auth.session.id : "loopback"——这就是"另一个浏览器的登录进行中"会得到 409 的来源。

前端驱动在 src/lib/mcp-sign-in.ts:先POST启动,拿到的链接必须过mcpSignInLink的 https 检查,否则立即取消并返回failed;随后每 1500ms 轮询一次状态,同时用onStarted把complete回调交给面板,使得"用户粘贴后立刻完成"不必等下一轮轮询。取消走DELETE且被记忆化(cancellation ??= ...),保证多次取消只发一次请求;abort事件与completed、sleep三者用Promise.race竞争,所以取消不必等待弹窗或状态请求落定。

常见失败与对应含义

现象 / 错误文本来源与含义
This server does not offer an OAuth sign-in. Add its token as a header instead.发现阶段拿不到可用的元数据(not-oauth)
This server only signs in apps registered with it in advance. Edit the server and add that app's client ID under Sign-in app.无registration_endpoint(no-registration)
Port <n> on this computer is in use...预注册客户端 + 固定回环端口被占用(port-busy)
A sign-in is already in progress in another browser.同一服务器被另一个所有者占用(409)
This redirect URL does not belong to the current sign-in.回调 origin/path/state/iss/参数重复等校验失败(400)
This sign-in has ended or is already being completed.重复提交、已取消或已过期(409)
This sign-in expired. Start again.超过 5 分钟有效期,监听已关闭
Your session ended. Start again.管理会话失效,revokeOwner已撤销流程

运行验证的实践建议

  1. 先在纯测试层定位问题:mcp-oauth.test.ts里的远程场景不依赖浏览器,出错信息直接指到具体校验分支,比跑端到端快得多。
  2. 端到端只在隔离夹具中跑:直接执行node --experimental-strip-types scripts/verify-mcp-sign-in.ts,不要把它接到正在使用的 OpenMausBot 上;脚本会自己launchVerificationServer()并在finally中依次关闭浏览器、预览、夹具服务、假 MCP 与假 OAuth。
  3. 保留证据:OMB_UI_EVIDENCE_DIR下的before.png/waiting.png/authenticating.png/signed-in.png是四张关键帧,分别对应"初始状态""已弹出粘贴表单""交换中""已登录并连通"。
  4. CI 里复用已装浏览器:设置OMB_AGENT_BROWSER_PATH与AGENT_BROWSER_EXECUTABLE_PATH可以跳过固定版本下载;若未设置,夹具会按大小与 SHA-256 校验后安装。
  5. 看日志而不是猜:临时数据目录与server-<timestamp>-<pid>.log会随失败信息一起打印,确认服务端行为前先读它。

这条验证链路的价值不在于"跑绿了一次",而在于它把 MCP 远程登录的每一条安全边界——一次性状态、单值参数、恒定时间比较、所有者隔离、取消即作废、登出即关监听、token 不进配置文件——都变成了可重复执行、可截图留证、可在 CI 中回归的断言。

  • 人工智能
  • AI Agent
  • AI 应用
  • 大模型
  • 交互助手
  • 工具调用
  • Agent 编排
  • 桌面应用

【免费下载链接】OpenMausBot

Open-source Grok Bot alternative with a virtual machine that bots can use

项目地址:https://gitcode.com/GitHub_Trending/op/OpenMausBot
点击查看免费下载
上一篇:PowerJob分布式调度框架终极指南:企业级任务调度与分布式计算架构解析
下一篇:amlogic-s9xxx-armbian 机顶盒 Armbian 网卡无法识别排查指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询