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:
JianFeeeee
2026-10-13 16:51:15 +08:00
parent c1733feeaa
commit 70c24ba158
4 changed files with 835 additions and 24 deletions

View File

@ -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",
})
}

View File

@ -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")
}
}

View File

@ -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
}