diff --git a/internal/core/channels_codearts_oauth_flow_test.go b/internal/core/channels_codearts_oauth_flow_test.go new file mode 100644 index 0000000..5ac1257 --- /dev/null +++ b/internal/core/channels_codearts_oauth_flow_test.go @@ -0,0 +1,186 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换 +// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、 +// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token" +// 无法防止把 OAuth 悄悄换回手填。 +func TestCodeArtsOAuthEndToEnd(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) + } + + // 假 STS 上游:校验请求形态,返回 credentials + refresh_token + var gotForm url.Values + var gotDpop string + sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotDpop = r.Header.Get("DPoP") + _ = r.ParseForm() + gotForm = r.PostForm + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "credentials": map[string]interface{}{ + "access_key_id": "AKTEST", + "secret_access_key": "SKTEST", + "security_token": "STTEST", + "expiration": "2026-12-31T00:00:00Z", + }, + "refresh_token": "RT-TEST", + }) + })) + defer sts.Close() + + // 把 STS 端点指到假上游 + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "codearts", + "codearts_sts": map[string]interface{}{"url": sts.URL}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + + // 1) start_login → 拿回调地址与登录 URL + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("start_login: %v", err) + } + m := out.JSON.(map[string]interface{}) + if m["error"] != nil { + t.Fatalf("start_login 报错: %v", m["error"]) + } + loginURL, _ := m["url"].(string) + redirectURI, _ := m["redirect_uri"].(string) + sess := m["session"].(map[string]interface{}) + sid, _ := sess["id"].(string) + t.Logf("login_url = %s", loginURL) + t.Logf("redirect_uri = %s", redirectURI) + + // 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl + if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") { + t.Fatalf("authorize base 不对: %s", loginURL) + } + for _, want := range []string{ + "client_id=codearts-agent", + "uri_scheme=codearts-agent", + "code_challenge_method=SHA-256", // 不是 S256 + "plugin-name=snap_AIIDE", + "plugin-version=5.2.0", + "theme=2", + "locale=zh-cn", + } { + if !strings.Contains(loginURL, want) { + t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL) + } + } + // 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程) + if strings.Contains(loginURL, "auth_callback_url") { + t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL) + } + // code_challenge 必须存在且非空 + if !strings.Contains(loginURL, "code_challenge=") || + strings.Contains(loginURL, "code_challenge=&") { + t.Errorf("缺 code_challenge: %s", loginURL) + } + + // 3) 模拟上游重定向到回调(带 code) + u, err := url.Parse(redirectURI) + if err != nil { + t.Fatal(err) + } + q := u.Query() + q.Set("code", "AUTHCODE-TEST") + u.RawQuery = q.Encode() + resp, err := http.Get(u.String()) + if err != nil { + t.Fatalf("打回调: %v", err) + } + resp.Body.Close() + + // 4) poll_login → 触发真实 STS 交换 + pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", + }) + if err != nil { + t.Fatalf("poll_login: %v", err) + } + pm := pout.JSON.(map[string]interface{}) + if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" { + t.Fatalf("poll_login 未成功: %#v", pm) + } + + // 5) 交换请求形态(判据的核心) + if gotDpop == "" { + t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth") + } + if strings.Count(gotDpop, ".") != 2 { + t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop) + } + if gotForm.Get("grant_type") != "authorization_code" { + t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type")) + } + if gotForm.Get("client_id") != "codearts-agent" { + t.Errorf("client_id = %q", gotForm.Get("client_id")) + } + if gotForm.Get("code_verifier") == "" { + t.Error("缺 code_verifier —— PKCE 没生效") + } + if gotForm.Get("code") != "AUTHCODE-TEST" { + t.Errorf("code = %q", gotForm.Get("code")) + } + ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback" + if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) { + t.Errorf("redirect_uri 主机不对: %s", redirectURI) + } + if gotForm.Get("redirect_uri") != redirectURI { + t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI) + } + + // 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK) + // 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。 + // 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。 + raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("凭据探针: %v", err) + } + w, _ := raw.(map[string]interface{}) + cj, _ := w["json"].(map[string]interface{}) + if cj == nil { + t.Fatalf("探针无 json: %#v", raw) + } + if cj["error"] != nil { + t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"]) + } + t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"]) + if cj["access_key_id"] != "AKTEST" { + t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"]) + } + for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} { + if cj[k] == nil || cj[k] == "" { + t.Errorf("凭据缺 %s(无法静默续期)", k) + } + } + if cj["refresh_token"] != "RT-TEST" { + t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"]) + } + _ = json.Marshal + + ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin", + }) +} diff --git a/internal/core/channels_codearts_oauth_test.go b/internal/core/channels_codearts_oauth_test.go new file mode 100644 index 0000000..e8172f1 --- /dev/null +++ b/internal/core/channels_codearts_oauth_test.go @@ -0,0 +1,112 @@ +package core + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" +) + +// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威 +// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是 +// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒, +// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。 +func TestCodeArtsDpopJwsVerifiesAsES256(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) + } + + got, err := ps.Call("channels", "__codearts_dpop_probe", + map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"}) + if err != nil { + t.Fatalf("probe: %v", err) + } + w, _ := got.(map[string]interface{}) + m, _ := w["json"].(map[string]interface{}) + if m == nil { + t.Fatalf("无 json: %#v", got) + } + if m["error"] != nil { + t.Fatalf("探针报错: %v", m["error"]) + } + jws, _ := m["jws"].(string) + if jws == "" { + t.Fatal("jws 为空") + } + parts := strings.Split(jws, ".") + if len(parts) != 3 { + t.Fatalf("JWS 应为三段,实得 %d 段", len(parts)) + } + dec := func(s string) []byte { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatalf("base64url 解码失败: %v", err) + } + return b + } + // ---- header ---- + var hdr map[string]interface{} + if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil { + t.Fatalf("header 不是 JSON: %v", err) + } + if hdr["alg"] != "ES256" { + t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"]) + } + if hdr["typ"] != "dpop+jwt" { + t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"]) + } + jwk, _ := hdr["jwk"].(map[string]interface{}) + if jwk == nil { + t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)") + } + // ---- 用 jwk 重建公钥 ---- + xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string)) + if err != nil { + t.Fatal(err) + } + yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string)) + if err != nil { + t.Fatal(err) + } + if len(xb) != 32 || len(yb) != 32 { + t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb)) + } + pub := &ecdsa.PublicKey{Curve: elliptic.P256(), + X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)} + + // ---- payload ---- + var pl map[string]interface{} + if err := json.Unmarshal(dec(parts[1]), &pl); err != nil { + t.Fatalf("payload 不是 JSON: %v", err) + } + for _, k := range []string{"htm", "htu", "iat", "jti"} { + if _, ok := pl[k]; !ok { + t.Errorf("payload 缺 %s(照参考 signDpopJws)", k) + } + } + if pl["htm"] != "POST" { + t.Errorf("htm = %v", pl["htm"]) + } + + // ---- 验签(关键:raw r||s,不是 DER)---- + sig := dec(parts[2]) + if len(sig) != 64 { + t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig)) + } + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if !ecdsa.Verify(pub, digest[:], r, s) { + t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP") + } +} diff --git a/internal/core/channels_codearts_port_test.go b/internal/core/channels_codearts_port_test.go new file mode 100644 index 0000000..23d49b8 --- /dev/null +++ b/internal/core/channels_codearts_port_test.go @@ -0,0 +1,65 @@ +package core + +import ( + "fmt" + "testing" + + "llmsproxy/internal/lua" +) + +// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的 +// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。 +// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。 +func TestCodeArtsLoopbackPortAtLeast10000(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) + } + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("start_login: %v", err) + } + m, _ := out.JSON.(map[string]interface{}) + if m == nil { + t.Fatalf("无 JSON: %#v", out) + } + if m["error"] != nil { + t.Fatalf("报错: %v", m["error"]) + } + ru, _ := m["redirect_uri"].(string) + if ru == "" { + t.Fatalf("无 redirect_uri: %#v", m) + } + t.Logf("redirect_uri = %s", ru) + var port int + if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil { + if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil { + t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru) + } + } + if port < 10000 { + t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port) + } + // 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH) + if !hasSuffix(ru, "/oauth/callback") { + t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru) + } + if s, _ := m["session"].(map[string]interface{}); s != nil { + if id, _ := s["id"].(string); id != "" { + ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin", + }) + } + } +} + +func hasSuffix(s, suf string) bool { + return len(s) >= len(suf) && s[len(s)-len(suf):] == suf +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 1205f5c..df7fd84 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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:)。 + 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)