mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): CodeArts 改为真实 IAM OAuth 登录(PKCE + DPoP ES256)
此前标成 flow="paste"(手填凭据)是**错的**:参考 login.ts/oauth.ts 默认走
新式 IAM OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
收 code 后带 DPoP 证明向华为 STS 端点换 AK/SK/securityToken。
逐项对齐参考:
- authorize URL:code_challenge_method=SHA-256(**不是** S256,错值会让
portal 回退旧 ticket 流程)、theme=2、locale=zh-cn、plugin-name=snap_AIIDE、
plugin-version=5.2.0,且**不得**含 auth_callback_url
- 回调路径 /oauth/callback,端口 ≥10000(低于此 portal 拒绝)
- PKCE:verifier=48 随机字节 base64url,challenge=sha256(verifier) base64url
- DPoP:ES256 JWS,header{alg,typ,jwk} + payload{htm,htu,iat,jti},
签名为 **raw r||s 64 字节**(openssl 出 DER,必须转格式)
- token 请求:POST 表单 + DPoP 头;终态只认 invalid_grant /
ExpiredRefreshToken(**不认** InvalidDPoPHeader)
- 凭据含 refresh_token/code_verifier/dpop_private_key_jwk 以支持静默续期
- 旧 ticket 流程保留作回退(pollForCredential 语义)
本机实测的坑(已修):xxd 不存在(用 od);openssl ec -text 会截断 priv
(d 用 asn1parse 的 [HEX DUMP],tail -c 32 拿到的是公钥 y);min_port 参数
读到 argv[2](与 out_path 冲突)。
判据(4 条,全绿):
- TestCodeArtsDpopJwsVerifiesAsES256:用 Go crypto/ecdsa 当权威验签器,
验 JWS 是 64 字节 raw 且真实可验(只断言"能拼出字符串"防不住格式错)
- TestCodeArtsOAuthEndToEnd:假 STS 上游,检查交换请求形态(DPoP 头、
grant_type、code_verifier、redirect_uri)与凭据含续期材料
- TestCodeArtsLoopbackPortAtLeast10000:量实际端口与回调路径
- TestCodeArtsSignatureMatchesReference:既有签名判据
This commit is contained in:
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe" }
|
||||
|
||||
|
||||
|
||||
@ -573,7 +573,20 @@ local PLATFORMS = {
|
||||
id = "codearts",
|
||||
name = "CodeArts(华为)",
|
||||
vendor = "huawei",
|
||||
flow = "paste",
|
||||
-- 不是 paste!参考默认走**新式 IAM OAuth**(PKCE + DPoP),
|
||||
-- 旧 ticket 流程仅作回退。我此前标成手填凭据是错的。
|
||||
flow = "callback",
|
||||
-- 回调路径必须是 /oauth/callback(参考 REDIRECT_PATH),且端口须 ≥10000
|
||||
-- (portal 拒绝低端口)。
|
||||
authorize = {
|
||||
base = "https://codearts.huaweicloud.com/portal/authorize",
|
||||
callback_path = "/oauth/callback",
|
||||
min_port = 10000,
|
||||
-- 参数由 buildCodeartsAuthorizeUrl 按参考逐字拼(含 SHA-256 而非 S256)
|
||||
},
|
||||
hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||||
"devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" },
|
||||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||||
adapter = "openai",
|
||||
models = { "CodeArts-Pangu" },
|
||||
@ -1012,6 +1025,319 @@ local function codeartsClaim(plat, tok, args)
|
||||
return { done = true, kind = "success", message = msg }
|
||||
end
|
||||
|
||||
-- ---- 基础编码原语(必须在**所有使用者之前**定义)----
|
||||
-- ⚠️ 本文件反复踩「local 前向引用」:local function 只从定义处向后可见,
|
||||
-- 更早的代码引用它就是 nil。这三个被 CodeArts 的 PKCE/DPoP 通过 base64url
|
||||
-- 与 hex 互转调用,必须放在 CodeArts 块之前。
|
||||
local function hexToBin(h)
|
||||
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
|
||||
end
|
||||
|
||||
local function b64url(s)
|
||||
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||||
end
|
||||
|
||||
-- b64urlRaw:无填充 base64url(JWS/JWK 用)
|
||||
local function b64urlRaw(bin)
|
||||
local e = base64_encode(bin)
|
||||
return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256)
|
||||
-- ============================================================================
|
||||
-- 逐段对齐参考 login.ts:runOAuthFlow + oauth.ts + channel-pack-rpc.ts 的
|
||||
-- `codearts.startLogin()`。此前我把它当成"手填凭据"(paste 流),**是错的**:
|
||||
-- 参考是完整 OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
|
||||
-- 收到 code 后走华为 STS 端点换 AK/SK/securityToken(带 DPoP 证明)。
|
||||
--
|
||||
-- 端点与常量(照抄):
|
||||
-- PORTAL_AUTHORIZE_BASE = https://codearts.huaweicloud.com/portal/authorize
|
||||
-- STS_TOKEN_ENDPOINT = https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens
|
||||
-- CLIENT_ID = codearts-agent(也是 uri_scheme)
|
||||
-- REDIRECT_PATH = /oauth/callback
|
||||
-- LOGIN_PLUGIN_NAME = snap_AIIDE LOGIN_PLUGIN_VERSION = 5.2.0
|
||||
-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn
|
||||
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
|
||||
-- 错值会回退到旧 ticket 流程)
|
||||
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
|
||||
local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize"
|
||||
local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"
|
||||
local CODEARTS_CLIENT_ID = "codearts-agent"
|
||||
local CODEARTS_REDIRECT_PATH = "/oauth/callback"
|
||||
local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE"
|
||||
local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0"
|
||||
local CODEARTS_OAUTH_THEME = "2"
|
||||
local CODEARTS_OAUTH_LOCALE = "zh-cn"
|
||||
|
||||
-- ES256 JWS 签名(照参考 signDpopJws):
|
||||
-- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}}
|
||||
-- payload = {htm, htu, iat, jti(32字节hex)}
|
||||
-- sig = ES256(privKey, base64url(header) .. "." .. base64url(payload))
|
||||
-- ⚠️ JWS 的 ES256 签名是 **raw r||s(各 32 字节)**,而 openssl 输出的是
|
||||
-- DER(SEQUENCE{INTEGER r, INTEGER s})。必须转格式,否则服务端验签失败。
|
||||
-- 用 openssl 签:把私钥写成 PEM,签名,再把 DER 转 raw。
|
||||
local function codeartsDpopJws(key, htm, htu)
|
||||
-- 1) 组 header/payload
|
||||
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
|
||||
local payload = json.encode({ htm = htm, htu = htu,
|
||||
iat = (os.time and os.time() or 0),
|
||||
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
|
||||
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
|
||||
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
|
||||
-- 2) openssl 签(PEM 私钥从 JWK 的 d 与 x/y 重建)
|
||||
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
|
||||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||||
local pem = dir .. "/k.pem"
|
||||
local dh = hexToBin((key.private.d:gsub("-", "+"):gsub("_", "/") ..
|
||||
string.rep("=", (4 - #key.private.d % 4) % 4)))
|
||||
-- 用 openssl 的 ecparam -genkey 无法指定 d;改用 asn1 构造太脆。
|
||||
-- 改为:生成新私钥后**用它的 d** 组 JWK(调用方须用同一把)。见 codeartsNewDpop。
|
||||
os.execute("rm -rf " .. dir)
|
||||
return nil, "内部错误:DPoP 签名需与密钥同时生成"
|
||||
end
|
||||
|
||||
-- codeartsNewDpop():生成密钥对**并返回可签名的 PEM**(两者必须同源 ——
|
||||
-- 公钥 JWK 必须来自签 JWS 的那把私钥,否则服务端按 header.jwk 验签会失败)。
|
||||
-- ⚠️ 两个本机实测的坑(都已踩):
|
||||
-- 1. **本机没有 xxd**(`command -v xxd` 为空)→ 用它取十六进制会静默得到
|
||||
-- 空串。改用 `od -An -tx1`。
|
||||
-- 2. `openssl ec -text` 打印 32 字节 priv 时**会截断**(实测只给 30 字节)→
|
||||
-- `d` 必须用 `openssl asn1parse` 的 `[HEX DUMP]`(精确 32 字节);
|
||||
-- 而 `tail -c 32` 拿到的是 SEC1 结构里**公钥 y**,不是 d。
|
||||
-- 3. 公钥点用 `openssl ec -text` 的 pub 段(完整 04||x||y,130 hex),
|
||||
-- 与 `-pubout` DER 末尾 65 字节交叉一致。
|
||||
local function codeartsNewDpop()
|
||||
local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12))
|
||||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||||
local pem = dir .. "/k.pem"
|
||||
os.execute("openssl ecparam -name prime256v1 -genkey -noout -out " .. pem .. " 2>/dev/null")
|
||||
local f = io.open(pem, "r")
|
||||
if not f then os.execute("rm -rf " .. dir) return nil, "openssl EC 生成失败" end
|
||||
local pemTxt = f:read("*a")
|
||||
f:close()
|
||||
-- d:asn1parse 的 OCTET STRING(权威、精确)
|
||||
local dh = io.popen("openssl asn1parse -in " .. pem
|
||||
.. " 2>/dev/null | grep -m1 'OCTET STRING' | awk -F': ' '{print $NF}'")
|
||||
local dHex = dh and (dh:read("*a") or ""):gsub("%s", "") or ""
|
||||
if dh then dh:close() end
|
||||
-- 公钥点:ec -text 的 pub 段
|
||||
local ph = io.popen("openssl ec -in " .. pem
|
||||
.. " -text -noout 2>/dev/null | awk '/^pub:/{f=1;next} /^ASN1/{f=0} f' | tr -d ' :' | tr -d '\n'")
|
||||
local pubHex = ph and (ph:read("*a") or ""):gsub("%s", "") or ""
|
||||
if ph then ph:close() end
|
||||
if #pubHex < 130 then
|
||||
-- 兜底:-pubout DER 的末尾 65 字节(未压缩点)
|
||||
local pf = io.popen("openssl ec -in " .. pem
|
||||
.. " -pubout -outform DER 2>/dev/null | tail -c 65 | od -An -tx1 | tr -d ' \n'")
|
||||
pubHex = pf and (pf:read("*a") or ""):gsub("%s", "") or ""
|
||||
if pf then pf:close() end
|
||||
end
|
||||
if #dHex < 64 or #pubHex < 130 then
|
||||
os.execute("rm -rf " .. dir)
|
||||
return nil, "无法解析 EC 密钥(d=" .. #dHex .. " pub=" .. #pubHex .. ")"
|
||||
end
|
||||
local x = pubHex:sub(3, 66)
|
||||
local y = pubHex:sub(67, 130)
|
||||
local key = {
|
||||
pem = pemTxt,
|
||||
public = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), y = b64urlRaw(hexToBin(y)) },
|
||||
private = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)),
|
||||
y = b64urlRaw(hexToBin(y)), d = b64urlRaw(hexToBin(dHex:sub(-64))) },
|
||||
}
|
||||
os.execute("rm -rf " .. dir)
|
||||
return key, nil
|
||||
end
|
||||
|
||||
-- DER(EC signature) → raw r||s(各 32 字节)。JWS ES256 必须是这个格式。
|
||||
local function codeartsDerToRawSig(der)
|
||||
-- SEQUENCE(0x30 len) INTEGER(0x02 rlen r) INTEGER(0x02 slen s)
|
||||
local i = 1
|
||||
if der:byte(i) ~= 0x30 then return nil end
|
||||
i = i + 1
|
||||
local l = der:byte(i)
|
||||
if l and l > 0x80 then i = i + (l - 0x80) end
|
||||
i = i + 1
|
||||
if der:byte(i) ~= 0x02 then return nil end
|
||||
i = i + 1
|
||||
local rlen = der:byte(i); i = i + 1
|
||||
local r = der:sub(i, i + rlen - 1); i = i + rlen
|
||||
if der:byte(i) ~= 0x02 then return nil end
|
||||
i = i + 1
|
||||
local slen = der:byte(i); i = i + 1
|
||||
local s = der:sub(i, i + slen - 1)
|
||||
local function pad(v)
|
||||
-- 去掉前导 0x00(DER 的正数填充),再左侧补零到 32 字节
|
||||
while #v > 32 and v:byte(1) == 0 do v = v:sub(2) end
|
||||
if #v < 32 then v = string.rep("\0", 32 - #v) .. v end
|
||||
return v
|
||||
end
|
||||
return pad(r) .. pad(s)
|
||||
end
|
||||
|
||||
-- codeartsDpopSign(key, htm, htu):产出 dpop+jwt(走真实 openssl 签名)。
|
||||
local function codeartsDpopSign(key, htm, htu)
|
||||
local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public })
|
||||
local payload = json.encode({ htm = htm, htu = htu,
|
||||
iat = (os.time and os.time() or 0),
|
||||
jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12)))
|
||||
.. sha256_hex(tostring(math.random(1e12))):sub(1, 32) })
|
||||
local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload)
|
||||
local dir = "/tmp/llmsproxy-dpopsig-" .. tostring(math.random(1e12))
|
||||
os.execute("mkdir -p " .. dir .. " 2>/dev/null")
|
||||
local pemPath, inPath = dir .. "/k.pem", dir .. "/in"
|
||||
local pf = io.open(pemPath, "w")
|
||||
if not pf then os.execute("rm -rf " .. dir) return nil, "无法写临时 PEM" end
|
||||
pf:write(key.pem)
|
||||
pf:close()
|
||||
local inf = io.open(inPath, "w")
|
||||
if not inf then os.execute("rm -rf " .. dir) return nil, "无法写签名输入" end
|
||||
inf:write(signingInput)
|
||||
inf:close()
|
||||
local h = io.popen("openssl dgst -sha256 -sign " .. pemPath .. " " .. inPath
|
||||
.. " 2>/dev/null | od -An -tx1 | tr -d ' \n'")
|
||||
local derHex = h and (h:read("*a") or ""):gsub("%s", "") or ""
|
||||
if h then h:close() end
|
||||
os.execute("rm -rf " .. dir)
|
||||
if derHex == "" then return nil, "openssl 签名失败" end
|
||||
local raw = codeartsDerToRawSig(hexToBin(derHex))
|
||||
if not raw then return nil, "DER→raw 转换失败" end
|
||||
return signingInput .. "." .. b64urlRaw(raw), nil
|
||||
end
|
||||
|
||||
-- codeartsRequestToken(body, key):向华为 STS 端点带 DPoP 换 token。
|
||||
-- 照参考 requestToken:POST 表单 + DPoP 头;响应 credentials{access_key_id,
|
||||
-- secret_access_key, security_token, expiration} + refresh_token。
|
||||
-- 终态判定只认两件事(照参考):`invalid_grant` 或 error_code 含
|
||||
-- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝,
|
||||
-- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。
|
||||
local function codeartsRequestToken(body, key)
|
||||
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS_STS_TOKEN)
|
||||
if not dpop then return nil, derr end
|
||||
local parts = {}
|
||||
for k, v in pairs(body) do
|
||||
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = CODEARTS_STS_TOKEN, method = "POST",
|
||||
headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" },
|
||||
body = table.concat(parts, "&"),
|
||||
})
|
||||
if err then return nil, "network error: " .. tostring(err) end
|
||||
if not resp then return nil, "无响应" end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then rec = nil end
|
||||
if resp.status ~= 200 or type(rec) ~= "table" or type(rec.credentials) ~= "table" then
|
||||
local code = tostring(rec and rec.error_code or "")
|
||||
local emsg = tostring(rec and (rec.error_msg or rec.error) or "")
|
||||
local terminal = (rec and rec.error == "invalid_grant")
|
||||
or code:find("ExpiredRefreshToken", 1, true) ~= nil
|
||||
return nil, "token 请求失败: HTTP " .. tostring(resp.status) .. " " .. code .. " " .. emsg,
|
||||
terminal
|
||||
end
|
||||
return rec, nil
|
||||
end
|
||||
|
||||
-- codeartsCredentialFrom(token, pkce, key):组装持久化凭据(照参考
|
||||
-- credentialFromTokenResponse:含 refresh 所需的 code_verifier 与 DPoP 私钥 JWK)。
|
||||
local function codeartsCredentialFrom(token, pkce, key)
|
||||
local c = token.credentials or {}
|
||||
return {
|
||||
access_key_id = c.access_key_id or "",
|
||||
secret_access_key = c.secret_access_key or "",
|
||||
security_token = c.security_token or "",
|
||||
expires_at = c.expiration or "",
|
||||
refresh_token = token.refresh_token,
|
||||
code_verifier = pkce.verifier,
|
||||
dpop_private_key_jwk = key.private,
|
||||
}
|
||||
end
|
||||
|
||||
-- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。
|
||||
-- ⚠️ 三处易错(参考注释都点名了):
|
||||
-- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别
|
||||
-- OAuth 授权,错值会**回退到旧 ticket 流程**(看起来"也能用",但不是我们要的)。
|
||||
-- 2. 参数里**不得**出现 `auth_callback_url` —— portal 仅凭 port 参数构造回调,
|
||||
-- 多余的回调地址会被视为异常并回退旧流程。
|
||||
-- 3. 端口必须 ≥10000。
|
||||
local function buildCodeartsAuthorizeUrl(port, pkce, ticketId)
|
||||
return CODEARTS_PORTAL_AUTHORIZE
|
||||
.. "?theme=" .. CODEARTS_OAUTH_THEME
|
||||
.. "&locale=" .. CODEARTS_OAUTH_LOCALE
|
||||
.. "&uri_scheme=" .. CODEARTS_CLIENT_ID
|
||||
.. "&client_id=" .. CODEARTS_CLIENT_ID
|
||||
.. "&port=" .. tostring(port)
|
||||
.. "&code_challenge=" .. pkce.challenge
|
||||
.. "&code_challenge_method=SHA-256"
|
||||
.. "&ticket_id=" .. rfc3986Escape(ticketId)
|
||||
.. "&plugin-name=" .. CODEARTS_LOGIN_PLUGIN_NAME
|
||||
.. "&plugin-version=" .. CODEARTS_LOGIN_PLUGIN_VERSION
|
||||
end
|
||||
|
||||
-- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。
|
||||
-- 照参考 pollForCredential:GET ?ticket_id&secret,头带 plugin-name/version,
|
||||
-- 最多 120 次、每次间隔 1s,瞬时失败跳过。
|
||||
local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
|
||||
local url = CODEARTS_CREDENTIAL_ENDPOINT .. "?ticket_id=" .. rfc3986Escape(ticketId)
|
||||
.. "&secret=" .. rfc3986Escape(secret)
|
||||
for _ = 1, 120 do
|
||||
local resp = __llmsproxy_http({
|
||||
url = url, method = "GET",
|
||||
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
|
||||
["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME,
|
||||
["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION },
|
||||
})
|
||||
if resp and resp.status == 200 then
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if ok and type(rec) == "table" then
|
||||
-- 两种形状(照参考 parseCredentialResponse)
|
||||
local c = rec.credential
|
||||
if type(c) == "table" and c.access and (c.securitytoken or c.securityToken) then
|
||||
return { access_key_id = c.access, secret_access_key = c.secret or "",
|
||||
security_token = c.securitytoken or c.securityToken,
|
||||
expires_at = c.expires_at or c.expiresAt or "",
|
||||
domain_id = rec.domain_id or "", user_id = rec.user_id or "",
|
||||
user_name = rec.user_name or "" }
|
||||
end
|
||||
local r = rec.result
|
||||
if type(r) == "table" and r.accessKeyId and r.securityToken then
|
||||
return { access_key_id = r.accessKeyId, secret_access_key = r.secretAccessKey or "",
|
||||
security_token = r.securityToken,
|
||||
expires_at = r.expiration or r.expiresAt or "" }
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 参考的 sleep(1000);单 VM 里不能长阻塞,故用较短的等待并显式让出
|
||||
os.execute("sleep 1")
|
||||
end
|
||||
return nil, "CodeArts 登录超时"
|
||||
end
|
||||
|
||||
-- 旧 ticket 流程(portal 判定新式不可用时回退)
|
||||
local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect"
|
||||
local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html"
|
||||
local CODEARTS_CREDENTIAL_ENDPOINT =
|
||||
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
|
||||
local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains"
|
||||
local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3"
|
||||
|
||||
-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url,
|
||||
-- challenge = sha256(verifier) 的 base64url)。
|
||||
-- 48 字节 base64url = 64 字符,符合 RFC 7636 的 43..128。
|
||||
local function codeartsPkcePair()
|
||||
-- 用 openssl 取 48 字节随机(Lua 的 math.random 熵不够)
|
||||
local h = io.popen("openssl rand 48 2>/dev/null | base64 -w0")
|
||||
local raw = h and h:read("*a") or ""
|
||||
if h then h:close() end
|
||||
raw = (raw:gsub("%s", ""))
|
||||
local verifier = (raw:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
|
||||
if #verifier < 43 then
|
||||
return nil, "无法生成 PKCE verifier"
|
||||
end
|
||||
local challenge = b64urlRaw(hexToBin(sha256_hex(verifier)))
|
||||
return { verifier = verifier, challenge = challenge }
|
||||
end
|
||||
|
||||
-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)----
|
||||
--
|
||||
-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死:
|
||||
@ -1561,15 +1887,6 @@ end
|
||||
-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。
|
||||
|
||||
-- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。
|
||||
local function b64url(s)
|
||||
return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", ""))
|
||||
end
|
||||
|
||||
-- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。
|
||||
local function hexToBin(h)
|
||||
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
|
||||
end
|
||||
|
||||
-- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)----
|
||||
-- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。
|
||||
--
|
||||
@ -3051,12 +3368,16 @@ end
|
||||
-- 返回 (redirect_url, nil) 或 (nil, err)。进程 pid 记入 plugin.state.loopbacks,
|
||||
-- 会话结束时由 reapLoopback 回收。
|
||||
local LOOPBACK_PY = [[
|
||||
import sys, json, os, socket, tempfile, threading
|
||||
import sys, json, os, socket, tempfile, threading, random
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。
|
||||
require_state = (prefix != "/authorize")
|
||||
# 各平台的回调查验规则不同,**不能一刀切**(照参考逐个平台看):
|
||||
# /authorize Trae:token 直接放 query,**不带 state**
|
||||
# /oauth/callback CodeArts:带 code + ticket_id(无 state),旧流程回退带 secret
|
||||
# 其余(有道 /auth/callback、Gemini /oauth-callback):标准授权码 + state 校验
|
||||
require_state = (prefix != "/authorize" and prefix != "/oauth/callback")
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
@ -3076,7 +3397,9 @@ class H(BaseHTTPRequestHandler):
|
||||
for k, v in q.items():
|
||||
payload[k] = v[0] if v else ""
|
||||
code = payload.get("code") or payload.get("authCode") or ""
|
||||
if require_state and not code:
|
||||
# codearts 旧流程回退只带 secret;authorize 只带 token —— 都不要求 code
|
||||
need_code = (prefix != "/authorize" and prefix != "/oauth/callback")
|
||||
if need_code and not code:
|
||||
self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8")
|
||||
self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return
|
||||
if not payload:
|
||||
@ -3106,13 +3429,31 @@ SERVERS = []
|
||||
class V6Server(HTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
# minPort:某些平台(CodeArts)要求回调端口 >= 10000,低于此值 portal 会拒绝。
|
||||
# 照参考 listenOnCallbackPort:bind(0) 拿到的端口若过小,关掉后用 [minPort,65535]
|
||||
# 的随机端口重试。
|
||||
min_port = int(sys.argv[5]) if len(sys.argv) > 5 else 0
|
||||
port = 0
|
||||
try:
|
||||
v6 = V6Server(("::1", 0), H)
|
||||
port = v6.server_address[1]
|
||||
SERVERS.append(v6)
|
||||
if min_port and port < min_port:
|
||||
v6.server_close()
|
||||
port = 0
|
||||
else:
|
||||
SERVERS.append(v6)
|
||||
except Exception:
|
||||
pass
|
||||
if port == 0:
|
||||
for _ in range(6):
|
||||
cand = random.randint(min_port, 65535) if min_port else 0
|
||||
try:
|
||||
v6 = V6Server(("::1", cand), H)
|
||||
port = v6.server_address[1]
|
||||
SERVERS.append(v6)
|
||||
break
|
||||
except Exception:
|
||||
port = 0
|
||||
try:
|
||||
v4 = HTTPServer(("127.0.0.1", port), H)
|
||||
if port == 0:
|
||||
@ -3158,7 +3499,7 @@ end
|
||||
-- cb_path 必须与平台注册值**逐字一致**(改了就是 redirect_uri_mismatch):
|
||||
-- 有道 /auth/callback(参考注释点名「精确路径」)、
|
||||
-- Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)。
|
||||
local function loopbackReceiver(sessionId, cbPath)
|
||||
local function loopbackReceiver(sessionId, cbPath, minPort)
|
||||
cbPath = cbPath or "/auth/callback"
|
||||
local script = loopbackScriptPath()
|
||||
if not script then return nil, "无法写入 loopback 脚本" end
|
||||
@ -3171,8 +3512,8 @@ local function loopbackReceiver(sessionId, cbPath)
|
||||
local wantState = (sess and sess.nonce) or ""
|
||||
-- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面);
|
||||
-- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。
|
||||
local cmd = ("python3 %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
|
||||
script, cbPath, outFile, wantState, portFile, sessionId)
|
||||
local cmd = ("python3 %s %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format(
|
||||
script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), sessionId)
|
||||
local h = io.popen(cmd)
|
||||
local pid = h and h:read("*l")
|
||||
if h then h:close() end
|
||||
@ -3471,7 +3812,7 @@ function plugin.start_login(req)
|
||||
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
|
||||
platform = plat.id,
|
||||
}
|
||||
if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" then
|
||||
if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" or plat.id == "codearts" then
|
||||
-- 这三个平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost),
|
||||
-- 且路径各自是注册值:有道 /auth/callback、Trae /authorize、
|
||||
-- Gemini /oauth-callback。回环地址由「打开授权页的那台机器」解析,而
|
||||
@ -3484,7 +3825,29 @@ function plugin.start_login(req)
|
||||
-- Gemini 注册的 redirect_uri 主机是 **localhost**(不是 127.0.0.1),
|
||||
-- 参考注释明确点名;两者在多数机器上等价,但必须与注册值逐字一致。
|
||||
if plat.id == "gemini" then sess.callback_host = "localhost" end
|
||||
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath)
|
||||
-- CodeArts:生成 PKCE 与 ticket_id,回调端口须 ≥10000(portal 拒绝低端口),
|
||||
-- 登录 URL 用**专用构建函数**(含 SHA-256 而非 S256,且不得有 auth_callback_url)。
|
||||
if plat.id == "codearts" then
|
||||
local pkce, perr = codeartsPkcePair()
|
||||
if not pkce then
|
||||
sess.state = "failed"
|
||||
out.error = "PKCE 生成失败: " .. tostring(perr)
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
local key, kerr = codeartsNewDpop()
|
||||
if not key then
|
||||
sess.state = "failed"
|
||||
out.error = "DPoP 密钥生成失败: " .. tostring(kerr)
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
sess.codearts = { pkce = pkce, key = key, ticket_id = newToken():sub(1, 64) }
|
||||
sess.min_port = (plat.authorize and plat.authorize.min_port) or 10000
|
||||
end
|
||||
local minPort = nil
|
||||
if plat.id == "codearts" then
|
||||
minPort = (plat.authorize and plat.authorize.min_port) or 10000
|
||||
end
|
||||
local cb_url, cb_err = loopbackReceiver(sess.id, cbPath, minPort)
|
||||
if cb_err then
|
||||
sess.state = "failed"
|
||||
out.error = "无法启动回调接收器: " .. cb_err
|
||||
@ -3690,7 +4053,13 @@ function plugin.start_login(req)
|
||||
-- 3) redirect_uri 必须百分号编码(含 :// 与 :,不编码会被授权页拒)。
|
||||
local port = gatewayPort()
|
||||
if plat.flow == "callback" then
|
||||
if type(plat.authorize) == "table" then
|
||||
if plat.id == "codearts" and sess.codearts then
|
||||
-- 用回调服务器**实际监听的端口**(≥10000),而不是网关端口 ——
|
||||
-- portal 凭 port 参数构造回调地址,错了就收不到 code。
|
||||
local cbPort = tonumber(sess.redirect_uri and sess.redirect_uri:match(":(%d+)/"))
|
||||
out.url = buildCodeartsAuthorizeUrl(cbPort or port, sess.codearts.pkce,
|
||||
sess.codearts.ticket_id)
|
||||
elseif type(plat.authorize) == "table" then
|
||||
local vars
|
||||
if sess.machine_id then
|
||||
vars = { machine_id = sess.machine_id, device_id = sess.device_id,
|
||||
@ -3872,14 +4241,48 @@ function plugin.poll_login(req)
|
||||
local cb, _ = readLoopbackCallback(s.id)
|
||||
local gotState = cb and (cb.state or "")
|
||||
local code = cb and (cb.code or cb.authCode or "")
|
||||
-- Trae 的回调不带 state(token 直接放在 query 里),非 Trae 必须 state 匹配。
|
||||
local stateOK = (s.platform == "trae") or (gotState ~= "" and gotState == s.nonce)
|
||||
-- Trae 的回调不带 state(token 直接放在 query 里);CodeArts 也只带 code
|
||||
-- (无 state)—— 两者都不校验 state。其余平台必须 state 匹配。
|
||||
local stateOK = (s.platform == "trae") or (s.platform == "codearts")
|
||||
or (gotState ~= "" and gotState == s.nonce)
|
||||
-- 只有「回调真到了且 state 对」才推进;文件还没出现时保持 pending,
|
||||
-- 绝不能把「用户还没操作完」当成失败。
|
||||
if cb and stateOK then
|
||||
local plat = platformById(s.platform)
|
||||
local credential, account, cerr
|
||||
if s.platform == "lobsterai" then
|
||||
if s.platform == "codearts" then
|
||||
-- CodeArts 新式 IAM OAuth:回调带 code → 走 STS 换 AK/SK(带 DPoP);
|
||||
-- 旧流程回退:回调带 secret → 轮询 ticket 端点。
|
||||
local ca = s.codearts or {}
|
||||
local secret = cb and (cb.secret or "")
|
||||
if secret ~= "" then
|
||||
local credTbl, terr = codeartsTicketPoll(ca.ticket_id, secret,
|
||||
CODEARTS_LOGIN_TICKET_NAME or nil, nil)
|
||||
if not credTbl then
|
||||
cerr = terr
|
||||
else
|
||||
credential, account = json.encode(credTbl), (credTbl.user_name or "codearts")
|
||||
end
|
||||
elseif code ~= "" and ca.pkce and ca.key then
|
||||
local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0
|
||||
local token, terr = codeartsRequestToken({
|
||||
client_id = CODEARTS_CLIENT_ID,
|
||||
code = code,
|
||||
code_verifier = ca.pkce.verifier,
|
||||
grant_type = "authorization_code",
|
||||
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS_REDIRECT_PATH,
|
||||
}, ca.key)
|
||||
if not token then
|
||||
cerr = terr
|
||||
else
|
||||
local cred = codeartsCredentialFrom(token, ca.pkce, ca.key)
|
||||
credential = json.encode(cred)
|
||||
account = ca.ticket_id and ("codearts-" .. tostring(ca.ticket_id):sub(1, 8)) or "codearts"
|
||||
end
|
||||
else
|
||||
cerr = "回调既无 code 也无 secret(无法完成 CodeArts 登录)"
|
||||
end
|
||||
elseif s.platform == "lobsterai" then
|
||||
-- 有道:authCode → exchange(与 oauth_callback 路径共用同一助手,防漂移)
|
||||
credential, account, cerr = lobsteraiExchange(plat, code, s)
|
||||
elseif s.platform == "trae" then
|
||||
@ -5345,6 +5748,12 @@ function plugin.set_endpoints(req)
|
||||
LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url)
|
||||
changed[#changed+1] = "lobster_activity_base.url"
|
||||
end
|
||||
-- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。
|
||||
-- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。
|
||||
if type(args.codearts_sts) == "table" and args.codearts_sts.url then
|
||||
CODEARTS_STS_TOKEN = tostring(args.codearts_sts.url)
|
||||
changed[#changed+1] = "codearts_sts.url"
|
||||
end
|
||||
if type(args.client_version) == "table" and plat.client_version then
|
||||
for _, k in ipairs({ "url", "path", "fallback" }) do
|
||||
if args.client_version[k] then
|
||||
@ -6020,6 +6429,45 @@ end
|
||||
|
||||
-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径,
|
||||
-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。
|
||||
-- plugin.__codearts_dpop_probe:只给测试用 —— 生成一把 DPoP 密钥并签一个
|
||||
-- 真实 JWS,供判据用标准 ES256 验签(验证 DER→raw 转换与 header/payload 形状)。
|
||||
-- plugin.__codearts_cred_probe:只给测试用 —— 读回最近一个 codearts 账号的
|
||||
-- 凭据,用来验证**续期材料真的落盘了**(refresh_token / code_verifier /
|
||||
-- DPoP 私钥 JWK)。只断言"登录返回成功"无法防止凭据被截断成裸 AK/SK。
|
||||
function plugin.__codearts_cred_probe(args)
|
||||
-- 先看账号池条目(新路径),再退到旧单账号引用(channels:<plat>)。
|
||||
local refs = {}
|
||||
local list = plugin.account_list({ platform = "codearts" })
|
||||
local j = list and list.json
|
||||
for _, a in ipairs((j and j.accounts) or {}) do
|
||||
if a.credentialRef then refs[#refs + 1] = a.credentialRef end
|
||||
end
|
||||
refs[#refs + 1] = "channels:codearts"
|
||||
for _, ref in ipairs(refs) do
|
||||
local cred = credentialFull(ref)
|
||||
if type(cred) == "table" then
|
||||
return { json = {
|
||||
ref = ref,
|
||||
access_key_id = cred.access_key_id,
|
||||
refresh_token = cred.refresh_token,
|
||||
code_verifier = cred.code_verifier,
|
||||
dpop_private_key_jwk = cred.dpop_private_key_jwk,
|
||||
} }
|
||||
end
|
||||
end
|
||||
return { json = { error = "未找到 codearts 凭据", refs = refs } }
|
||||
end
|
||||
|
||||
function plugin.__codearts_dpop_probe(args)
|
||||
args = args or {}
|
||||
local key, kerr = codeartsNewDpop()
|
||||
if not key then return { json = { error = tostring(kerr) } } end
|
||||
local jws, serr = codeartsDpopSign(key, tostring(args.htm or "POST"),
|
||||
tostring(args.htu or "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"))
|
||||
if not jws then return { json = { error = tostring(serr) } } end
|
||||
return { json = { jws = jws } }
|
||||
end
|
||||
|
||||
-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证
|
||||
-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。
|
||||
function plugin.__qoder_refresh_probe(args)
|
||||
|
||||
Reference in New Issue
Block a user