Skip to content

Commit e9257be

Browse files
committed
修复 Codex 账号额度导入与启停刷新
- 后端管理端新增 secret-ref 只读解析入口,并在 auth-files 列表中从受保护 id_token 派生 chatgpt_account_id、plan_type,同时保留 account_id 回退。 - 后端启停认证文件时清理账号运行期不可用、错误、重试、quota 和模型 cooldown 状态,并同步清理 auth manager 运行期阻塞状态。 - WebUI 扩展 Codex 账号 ID 解析来源,支持顶层、metadata、attributes 字段并回退 id_token。 - WebUI 账号列表和额度页改为首屏阻塞加载、后续刷新保留已有列表,额度页首屏加载显示统一提示。 - 补充管理端 auth-files secret/JWT/回退测试、启停状态清理测试、auth manager 清理测试和前端 resolver 测试。
1 parent e0d0e76 commit e9257be

13 files changed

Lines changed: 681 additions & 14 deletions

File tree

resources/backend/source/internal/api/handlers/management/auth_files.go

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -432,8 +432,10 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
432432
log.WithError(err).Warnf("failed to stat auth file %s", path)
433433
}
434434
}
435-
if claims := extractCodexIDTokenClaims(auth); claims != nil {
436-
entry["id_token"] = claims
435+
if fields := extractCodexAuthFileFields(auth); fields != nil {
436+
for key, value := range fields {
437+
entry[key] = value
438+
}
437439
}
438440
// Expose priority from Attributes (set by synthesizer from JSON "priority" field).
439441
// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).
@@ -469,21 +471,52 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
469471
return entry
470472
}
471473

472-
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
473-
if auth == nil || auth.Metadata == nil {
474+
func extractCodexAuthFileFields(auth *coreauth.Auth) gin.H {
475+
if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
476+
return nil
477+
}
478+
479+
result := gin.H{}
480+
if claims := extractCodexIDTokenClaims(auth); claims != nil {
481+
result["id_token"] = claims
482+
for key, value := range claims {
483+
result[key] = value
484+
}
485+
}
486+
if _, ok := result["chatgpt_account_id"]; !ok {
487+
if accountID := authStringField(auth, "chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"); accountID != "" {
488+
result["chatgpt_account_id"] = accountID
489+
}
490+
}
491+
if _, ok := result["plan_type"]; !ok {
492+
if planType := authStringField(auth, "plan_type", "planType"); planType != "" {
493+
result["plan_type"] = planType
494+
}
495+
}
496+
497+
if len(result) == 0 {
474498
return nil
475499
}
476-
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
500+
return result
501+
}
502+
503+
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
504+
if auth == nil {
477505
return nil
478506
}
479-
idTokenRaw, ok := auth.Metadata["id_token"].(string)
480-
if !ok {
507+
idTokenRaw := authStringField(auth, "id_token", "idToken")
508+
if idTokenRaw == "" {
481509
return nil
482510
}
483511
idToken := strings.TrimSpace(idTokenRaw)
484512
if idToken == "" {
485513
return nil
486514
}
515+
if resolved, err := config.ResolveCodexCliPlusSecretRefForRead("auth-files."+strings.TrimSpace(auth.ID)+".id_token", idToken); err == nil {
516+
idToken = strings.TrimSpace(resolved)
517+
} else {
518+
log.WithError(err).Debugf("failed to resolve Codex id_token secret reference for auth %s", auth.ID)
519+
}
487520
claims, err := codex.ParseJWTToken(idToken)
488521
if err != nil || claims == nil {
489522
return nil
@@ -509,6 +542,40 @@ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
509542
return result
510543
}
511544

545+
func authStringField(auth *coreauth.Auth, keys ...string) string {
546+
if auth == nil {
547+
return ""
548+
}
549+
if auth.Metadata != nil {
550+
for _, key := range keys {
551+
if value := strings.TrimSpace(authMetadataString(auth.Metadata[key])); value != "" {
552+
return value
553+
}
554+
}
555+
}
556+
if auth.Attributes != nil {
557+
for _, key := range keys {
558+
if value := strings.TrimSpace(auth.Attributes[key]); value != "" {
559+
return value
560+
}
561+
}
562+
}
563+
return ""
564+
}
565+
566+
func authMetadataString(value any) string {
567+
switch v := value.(type) {
568+
case string:
569+
return strings.TrimSpace(v)
570+
case json.Number:
571+
return strings.TrimSpace(v.String())
572+
case fmt.Stringer:
573+
return strings.TrimSpace(v.String())
574+
default:
575+
return ""
576+
}
577+
}
578+
512579
func authEmail(auth *coreauth.Auth) string {
513580
if auth == nil {
514581
return ""
@@ -1109,6 +1176,8 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
11091176
}
11101177

11111178
// Update disabled state
1179+
clearAuthRuntimeBlockingState(targetAuth)
1180+
h.authManager.ClearRuntimeBlockingState(targetAuth.ID)
11121181
targetAuth.Disabled = *req.Disabled
11131182
if *req.Disabled {
11141183
targetAuth.Status = coreauth.StatusDisabled
@@ -1127,6 +1196,23 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
11271196
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
11281197
}
11291198

1199+
func clearAuthRuntimeBlockingState(auth *coreauth.Auth) {
1200+
if auth == nil {
1201+
return
1202+
}
1203+
if auth.ID != "" && len(auth.ModelStates) > 0 {
1204+
modelRegistry := registry.GetGlobalRegistry()
1205+
for model := range auth.ModelStates {
1206+
modelRegistry.ClearModelQuotaExceeded(auth.ID, model)
1207+
}
1208+
}
1209+
auth.Unavailable = false
1210+
auth.LastError = nil
1211+
auth.NextRetryAfter = time.Time{}
1212+
auth.Quota = coreauth.QuotaState{}
1213+
auth.ModelStates = nil
1214+
}
1215+
11301216
// PatchAuthFileFields updates editable fields (prefix, proxy_url, headers, priority, note) of an auth file.
11311217
func (h *Handler) PatchAuthFileFields(c *gin.Context) {
11321218
if h.authManager == nil {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/gin-gonic/gin"
13+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14+
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
15+
)
16+
17+
func TestListAuthFiles_CodexSecretIDTokenReturnsAccountFields(t *testing.T) {
18+
t.Setenv("MANAGEMENT_PASSWORD", "")
19+
gin.SetMode(gin.TestMode)
20+
21+
idToken := makeCodexIDToken(t, "chatgpt-account-secret", "plus")
22+
secretServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
if got := r.Header.Get("Authorization"); got != "Bearer test-secret-token" {
24+
t.Fatalf("Authorization = %q, want bearer token", got)
25+
}
26+
if r.URL.Path != "/v1/secrets/codex-id-token" {
27+
t.Fatalf("secret path = %q", r.URL.Path)
28+
}
29+
_ = json.NewEncoder(w).Encode(map[string]string{"value": idToken})
30+
}))
31+
t.Cleanup(secretServer.Close)
32+
t.Setenv("CCP_SECRET_BROKER_URL", secretServer.URL)
33+
t.Setenv("CCP_SECRET_BROKER_TOKEN", "test-secret-token")
34+
35+
manager := coreauth.NewManager(nil, nil, nil)
36+
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
37+
ID: "codex-secret",
38+
FileName: "codex-secret.json",
39+
Provider: "codex",
40+
Attributes: map[string]string{
41+
"runtime_only": "true",
42+
},
43+
Metadata: map[string]any{
44+
"id_token": "ccp-secret://codex-id-token",
45+
},
46+
}); errRegister != nil {
47+
t.Fatalf("register auth: %v", errRegister)
48+
}
49+
50+
entry := listSingleAuthFile(t, manager)
51+
52+
if got := entry["chatgpt_account_id"]; got != "chatgpt-account-secret" {
53+
t.Fatalf("chatgpt_account_id = %#v", got)
54+
}
55+
if got := entry["plan_type"]; got != "plus" {
56+
t.Fatalf("plan_type = %#v", got)
57+
}
58+
59+
idTokenClaims, ok := entry["id_token"].(map[string]any)
60+
if !ok {
61+
t.Fatalf("id_token claims missing: %#v", entry["id_token"])
62+
}
63+
if got := idTokenClaims["chatgpt_account_id"]; got != "chatgpt-account-secret" {
64+
t.Fatalf("id_token.chatgpt_account_id = %#v", got)
65+
}
66+
if text := strings.TrimSpace(toJSON(t, entry)); strings.Contains(text, idToken) || strings.Contains(text, "ccp-secret://") {
67+
t.Fatalf("auth file response exposed token material: %s", text)
68+
}
69+
}
70+
71+
func TestListAuthFiles_CodexVaultIDTokenReturnsAccountFields(t *testing.T) {
72+
t.Setenv("MANAGEMENT_PASSWORD", "")
73+
gin.SetMode(gin.TestMode)
74+
75+
idToken := makeCodexIDToken(t, "chatgpt-account-vault", "team")
76+
secretServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
if r.URL.Path != "/v1/secrets/vault-token" {
78+
t.Fatalf("secret path = %q", r.URL.Path)
79+
}
80+
_ = json.NewEncoder(w).Encode(map[string]string{"value": idToken})
81+
}))
82+
t.Cleanup(secretServer.Close)
83+
t.Setenv("CCP_SECRET_BROKER_URL", secretServer.URL)
84+
t.Setenv("CCP_SECRET_BROKER_TOKEN", "test-secret-token")
85+
86+
manager := coreauth.NewManager(nil, nil, nil)
87+
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
88+
ID: "codex-vault",
89+
FileName: "codex-vault.json",
90+
Provider: "codex",
91+
Attributes: map[string]string{
92+
"runtime_only": "true",
93+
},
94+
Metadata: map[string]any{
95+
"id_token": "vault://vault-token",
96+
},
97+
}); errRegister != nil {
98+
t.Fatalf("register auth: %v", errRegister)
99+
}
100+
101+
entry := listSingleAuthFile(t, manager)
102+
103+
if got := entry["chatgpt_account_id"]; got != "chatgpt-account-vault" {
104+
t.Fatalf("chatgpt_account_id = %#v", got)
105+
}
106+
if got := entry["plan_type"]; got != "team" {
107+
t.Fatalf("plan_type = %#v", got)
108+
}
109+
}
110+
111+
func TestListAuthFiles_CodexFallsBackToMetadataAccountID(t *testing.T) {
112+
t.Setenv("MANAGEMENT_PASSWORD", "")
113+
gin.SetMode(gin.TestMode)
114+
115+
manager := coreauth.NewManager(nil, nil, nil)
116+
if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
117+
ID: "codex-fallback",
118+
FileName: "codex-fallback.json",
119+
Provider: "codex",
120+
Attributes: map[string]string{
121+
"runtime_only": "true",
122+
},
123+
Metadata: map[string]any{
124+
"id_token": "not-a-jwt",
125+
"account_id": "chatgpt-account-fallback",
126+
"plan_type": "plus",
127+
},
128+
}); errRegister != nil {
129+
t.Fatalf("register auth: %v", errRegister)
130+
}
131+
132+
entry := listSingleAuthFile(t, manager)
133+
134+
if got := entry["chatgpt_account_id"]; got != "chatgpt-account-fallback" {
135+
t.Fatalf("chatgpt_account_id = %#v", got)
136+
}
137+
if got := entry["plan_type"]; got != "plus" {
138+
t.Fatalf("plan_type = %#v", got)
139+
}
140+
if _, ok := entry["id_token"]; ok {
141+
t.Fatalf("invalid id_token should not be exposed: %#v", entry["id_token"])
142+
}
143+
}
144+
145+
func listSingleAuthFile(t *testing.T, manager *coreauth.Manager) map[string]any {
146+
t.Helper()
147+
148+
handler := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
149+
rec := httptest.NewRecorder()
150+
ginCtx, _ := gin.CreateTestContext(rec)
151+
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
152+
153+
handler.ListAuthFiles(ginCtx)
154+
155+
if rec.Code != http.StatusOK {
156+
t.Fatalf("list status = %d, body = %s", rec.Code, rec.Body.String())
157+
}
158+
159+
var payload struct {
160+
Files []map[string]any `json:"files"`
161+
}
162+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
163+
t.Fatalf("decode response: %v", err)
164+
}
165+
if len(payload.Files) != 1 {
166+
t.Fatalf("files length = %d, body = %s", len(payload.Files), rec.Body.String())
167+
}
168+
return payload.Files[0]
169+
}
170+
171+
func makeCodexIDToken(t *testing.T, accountID, planType string) string {
172+
t.Helper()
173+
174+
header := map[string]any{"alg": "none", "typ": "JWT"}
175+
payload := map[string]any{
176+
"https://api.openai.com/auth": map[string]any{
177+
"chatgpt_account_id": accountID,
178+
"chatgpt_plan_type": planType,
179+
},
180+
}
181+
return encodeJWTPart(t, header) + "." + encodeJWTPart(t, payload) + ".signature"
182+
}
183+
184+
func encodeJWTPart(t *testing.T, payload any) string {
185+
t.Helper()
186+
187+
data, err := json.Marshal(payload)
188+
if err != nil {
189+
t.Fatalf("marshal JWT payload: %v", err)
190+
}
191+
return base64.RawURLEncoding.EncodeToString(data)
192+
}
193+
194+
func toJSON(t *testing.T, payload any) string {
195+
t.Helper()
196+
197+
data, err := json.Marshal(payload)
198+
if err != nil {
199+
t.Fatalf("marshal payload: %v", err)
200+
}
201+
return string(data)
202+
}

0 commit comments

Comments
 (0)