mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。
## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
由服务端浏览器(VNC)承载授权页,重定向落回本机。
## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。
## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。
## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。
## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。
## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。
## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。
## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存
## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
183 lines
7.1 KiB
Go
183 lines
7.1 KiB
Go
package core
|
||
|
||
import (
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
|
||
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
|
||
//
|
||
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
|
||
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
|
||
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
|
||
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
|
||
// - redirect_uri 必须百分号编码(含 :// 与 :)
|
||
func TestCallbackAuthorizeUrls(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
|
||
t.Helper()
|
||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("%s start_login: %v", plat, err)
|
||
}
|
||
j := out.JSON.(map[string]interface{})
|
||
u, _ := j["url"].(string)
|
||
return u, j
|
||
}
|
||
|
||
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
|
||
u, _ := start(t, "trae")
|
||
if u == "" {
|
||
t.Fatal("trae 未给出授权 URL")
|
||
}
|
||
if !strings.Contains(u, "auth_callback_url=") {
|
||
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
|
||
}
|
||
if strings.Contains(u, "redirect_uri=") {
|
||
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
|
||
}
|
||
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
|
||
if !strings.Contains(u, want) {
|
||
t.Fatalf("trae 缺参数 %s: %s", want, u)
|
||
}
|
||
}
|
||
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
|
||
if strings.Contains(u, "authorization&") {
|
||
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
|
||
}
|
||
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
|
||
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
|
||
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
|
||
// 还可能被风控拦;x_* 是客户端形态伪装。
|
||
parsed, err := url.Parse(u)
|
||
if err != nil {
|
||
t.Fatalf("trae 授权 URL 无法解析: %v", err)
|
||
}
|
||
q := parsed.Query()
|
||
for _, k := range []string{
|
||
"login_version", "auth_from", "login_channel", "plugin_version",
|
||
"auth_type", "client_id", "redirect", "login_trace_id",
|
||
"auth_callback_url", "machine_id", "device_id",
|
||
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
|
||
"x_os_version", "x_app_version", "x_app_type",
|
||
} {
|
||
if q.Get(k) == "" {
|
||
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
|
||
}
|
||
}
|
||
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
|
||
m, d := q.Get("machine_id"), q.Get("device_id")
|
||
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
|
||
t.Fatalf("machine_id 应为 hex32: %q", m)
|
||
}
|
||
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
|
||
t.Fatalf("device_id 应为 hex32: %q", d)
|
||
}
|
||
if m == d {
|
||
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
|
||
}
|
||
joined := m + d
|
||
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
|
||
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
|
||
}
|
||
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
|
||
// 会被上游以「该设备已签到」拦截)
|
||
u2, _ := start(t, "trae")
|
||
q2, _ := url.Parse(u2)
|
||
if q2.Query().Get("device_id") == d {
|
||
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
|
||
}
|
||
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
|
||
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
|
||
if cb := q.Get("auth_callback_url"); cb != "" {
|
||
if !strings.HasSuffix(cb, "/authorize") {
|
||
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
|
||
}
|
||
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
|
||
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
|
||
u, _ := start(t, "gemini")
|
||
parsed, err := url.Parse(u)
|
||
if err != nil {
|
||
t.Fatalf("授权 URL 无法解析: %v", err)
|
||
}
|
||
ru := parsed.Query().Get("redirect_uri")
|
||
if !strings.HasPrefix(ru, "http://localhost:") {
|
||
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
|
||
}
|
||
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
|
||
// 换成别的路径就是 redirect_uri_mismatch。
|
||
if !strings.HasSuffix(ru, "/oauth-callback") {
|
||
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
|
||
}
|
||
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
|
||
if parsed.Query().Get(k) == "" {
|
||
t.Fatalf("gemini 缺参数 %s: %s", k, u)
|
||
}
|
||
}
|
||
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
|
||
if got := parsed.Query().Get("access_type"); got != "offline" {
|
||
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
|
||
}
|
||
if got := parsed.Query().Get("prompt"); got != "consent" {
|
||
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
|
||
}
|
||
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
|
||
if n := strings.Count(u, "redirect_uri="); n != 1 {
|
||
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
|
||
}
|
||
if n := strings.Count(u, "client_id="); n != 1 {
|
||
t.Fatalf("client_id 出现 %d 次: %s", n, u)
|
||
}
|
||
})
|
||
|
||
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
|
||
u, _ := start(t, "lobsterai")
|
||
if !strings.Contains(u, "/portal#/login?") {
|
||
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
|
||
}
|
||
if strings.Contains(u, "/login&") {
|
||
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
|
||
}
|
||
if !strings.Contains(u, "source=electron") {
|
||
t.Fatalf("lobsterai 缺 source=electron: %s", u)
|
||
}
|
||
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
|
||
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
|
||
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
|
||
}
|
||
})
|
||
|
||
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
|
||
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
|
||
u, _ := start(t, plat)
|
||
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
|
||
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
|
||
}
|
||
if strings.Contains(u, "{port}") {
|
||
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
|
||
}
|
||
}
|
||
})
|
||
}
|