site logo

Marico's space

我以为给本地 AI Gateway 加个 Google 风格 OAuth,一个晚上就能搞定

前端技术 2026-05-12 11:29:16 8

最近折腾了一下给本地 AI Gateway 加 Google 风格的 OAuth,本以为一个晚上能搞定——打开浏览器、拿 OAuth code、换 token、保存账号、收工。理论上就这么简单。

结果呢?把 Antigravity 接入 CliGate 变成了一串连锁问题,每个看起来都解决了,下一个请求又挂了。

这种集成 bug 从远处看特别不起眼:

  • 登录窗口打开了
  • Google 登录成功了
  • 成功页面关掉了
  • 然后你的 app 实际上并没有一个可用的账号

如果你做过本地开发者工具的浏览器 OAuth,这种模式应该不陌生。

环境说明

CliGate 是一个本地 AI 编程工具的网关。

它让 Claude CodeCodex CLIGemini CLIOpenClaw 这些工具都连到本地同一个控制面板上,CliGate 负责账号池管理、API key 路由、协议转换、仪表盘聊天,还有针对每个工具的特定配置。

Antigravity 支持的目标听起来很简单:

  • 仪表盘里能添加 Antigravity 账号
  • 用户可以用 Google 登录
  • 账号本地持久化
  • 账号可以作为聊天源被选中
  • 聊天请求通过 Antigravity 上游路由出去

但这不是一个问题。

至少是四个。

问题一:登录成功了,Token 交换却失败了

第一个 bug 长这样:

Google token exchange failed: 400
error_description: "client_secret is missing."

浏览器登录本身没问题。失败发生在重定向之后,CliGate 尝试把授权码换成 token 的时候。

这个区别很重要。

打开 Google 同意屏幕并不等于 OAuth 流程有效。它只证明你的授权 URL 足够有效,能让用户登录。

真正的门槛是 token 交换。

我先试了干净的做法:

  • 去掉本地对缺失 secret 的预检查
  • 加上 PKCE(Proof Key for Code Exchange,代码交换证明)
  • token 交换时发送 code_verifier

这解决了明显的本地错误处理问题,但 Google 还是返回:

client_secret is missing

这说明当前的 OAuth 客户端仍然被当作机密客户端(confidential client)处理。

PKCE 很好,但它不会把 Google 期望使用 client_secret 的客户端魔法般转换成公共客户端(public client)。

所以本地集成必须停止假装浏览器重定向是最难的部分。真正的约束是 OAuth 客户端注册本身。

问题二:账号保存了,但刷新不了

token 交换路径搞定之后,下一个问题更隐蔽。

一个通过浏览器添加的账号可以工作一次,然后后续刷新时失败,因为原始的 OAuth 客户端上下文实际上已经被遗忘了。

这是持久化 bug,不是认证 bug。

修复方案是把 OAuth 客户端信息和账号一起保存,而不是假设一个全局的运行时默认值以后还够用。

所以每个 Antigravity 账号现在都保留自己的 OAuth 客户端上下文用于刷新:

{ "email": "user@example.com", "oauthClientKey": "antigravity-enterprise", "oauthClientConfig": { "key": "antigravity-enterprise", "clientId": "...", "clientSecret": "..." }
}

这样,浏览器添加的账号重启后仍然能刷新,而不是默默依赖当时环境变量里碰巧有什么。

问题三:OAuth 成功了,但账号还是不能用

然后来了个最让我意外的问题。

账号可以成功添加,但下一步报错:

ANTIGRAVITY_PROJECT_ERROR: cloudaicompanionProject missing

这个跟 OAuth 没关系了。

问题出在登录后的平台元数据,上游在服务实际请求之前想要这些。

我对比了 CliGate 和另一个专注于 Antigravity 的开源项目,发现了一个有用的设计差异:

  • 我的第一版实现把缺少 cloudaicompanionProject 当成致命的登录失败
  • 他们的实现把它当成可恢复的状态

这才是正确的做法。

所以流程从:

  1. 登录成功
  2. 项目 ID 缺失
  3. 硬失败

变成了:

  1. 登录成功
  2. 不管怎样先保存账号
  3. 尝试 loadCodeAssist
  4. 如果项目 ID 缺失,走降级路径继续
  5. 后续再解析或持久化一个可用的项目 ID

这很重要,因为"无法立即解析所有东西"在实际集成中很正常。产品代码需要退化路径。

问题四:账号存在,但用户选不了

这个又完全不一样了。

账号在账号标签页里显示。

但在仪表盘聊天源选择器里不显示

这说明 bug 根本不是 OAuth、token 存储或上游认证的问题。只是 UI(用户界面)数据源没接上。

CliGate 的聊天源选择器还在只返回:

  • ChatGPT 账号
  • Claude 账号
  • API keys

Antigravity 账号接入了账号管理,但没接入聊天源目录。

所以用户体验是:

  • 账号添加成功
  • 在账号里可见
  • 在聊天里不可见

原因搞清楚之后修复很简单:

  • 把 Antigravity 账号纳入 /api/chat/sources
  • 展示它们可用的模型列表
  • 在普通和流式聊天路径里都加上 Antigravity 的发送分支

这种 bug 会浪费很多时间,因为用户看到的表现听起来像是"登录坏了",但登录实际上已经成功了。

真正的教训

做本地开发者工具的时候,我总是遇到同一个模式:

大家谈"加 OAuth"就像它是一个勾选框。

但它不是。

这次真正的工作是:

  1. 让浏览器流程启动
  2. 让 token 交换匹配实际的 OAuth 客户端约束
  3. 持久化足够的客户端上下文,让刷新之后还能工作
  4. 容忍缺失的上游元数据(比如项目 ID)
  5. 把新账号类型接入用户真正使用的界面

把这些都做完之后,用户体验才是:

"我点了登录,在聊天里选了账号,发了条消息。"

用户应该看到的就是这部分。

剩下的都是让"简单"集成不再在下一步挂掉的隐形工程工作。

一个小 Before/After

之前:

  • 浏览器登录打开了
  • token 交换失败了
  • 或者账号保存了但刷新不了
  • 或者账号保存了但没有项目 ID
  • 或者账号存在但聊天里选不了

之后:

  • 账号登录完成
  • 客户端上下文和账号一起持久化
  • 项目 ID 解析优雅降级
  • Antigravity 作为一等聊天源出现
  • 聊天请求真的能通过它路由出去

这才是"支持已添加"的更好定义,而不是 OAuth 之后弹出一个绿色成功弹窗。

如果你也在做类似的东西

最大的错误是把认证当成整个集成。

通常不是。

如果你的 app 新增了一个账号类型,在宣告完成之前问自己这几个问题:

  • 重启后 token 能刷新吗?
  • 上游登录后需要额外的元数据吗?
  • 账号在每个相关的选择器和路由里都出现吗?
  • 一次成功的登录还可能导致第一次真实请求失败吗?

如果任何一个答案是"是",那 OAuth 成功不等于完成。

真正的完成是:用户能在他们预期的地方真正用上这个账号。

原文链接:https://dev.to/...