diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2e471ae8ca..c51c111ca7 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -425,31 +425,37 @@ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H { if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { return nil } - idTokenRaw, ok := auth.Metadata["id_token"].(string) - if !ok { - return nil - } - idToken := strings.TrimSpace(idTokenRaw) - if idToken == "" { - return nil - } - claims, err := codex.ParseJWTToken(idToken) - if err != nil || claims == nil { - return nil - } result := gin.H{} - if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" { - result["chatgpt_account_id"] = v - } - if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" { - result["plan_type"] = v + + // Step 1: unconditionally parse id_token as the baseline source. + // Subscription date fields only exist in id_token, so this must always run. + if idTokenRaw, ok := auth.Metadata["id_token"].(string); ok { + if idToken := strings.TrimSpace(idTokenRaw); idToken != "" { + if claims, err := codex.ParseJWTToken(idToken); err == nil && claims != nil { + if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" { + result["chatgpt_account_id"] = v + } + if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" { + result["plan_type"] = v + } + if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil { + result["chatgpt_subscription_active_start"] = v + } + if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil { + result["chatgpt_subscription_active_until"] = v + } + } + } } - if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil { - result["chatgpt_subscription_active_start"] = v + + // Step 2: override with explicit values from the JSON file (Metadata) if present. + // These take priority because the user may have set them directly in the imported file. + if v, ok := auth.Metadata["account_id"].(string); ok && strings.TrimSpace(v) != "" { + result["chatgpt_account_id"] = strings.TrimSpace(v) } - if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil { - result["chatgpt_subscription_active_until"] = v + if v, ok := auth.Metadata["plan_type"].(string); ok && strings.TrimSpace(v) != "" { + result["plan_type"] = strings.TrimSpace(v) } if len(result) == 0 { diff --git a/internal/auth/codex/jwt_parser.go b/internal/auth/codex/jwt_parser.go index 130e86420a..db49781894 100644 --- a/internal/auth/codex/jwt_parser.go +++ b/internal/auth/codex/jwt_parser.go @@ -100,3 +100,12 @@ func (c *JWTClaims) GetUserEmail() string { func (c *JWTClaims) GetAccountID() string { return c.CodexAuthInfo.ChatgptAccountID } + +// GetClientID returns the first audience value from the JWT claims, which represents +// the OAuth client_id used during token issuance. +func (c *JWTClaims) GetClientID() string { + if len(c.Aud) > 0 { + return c.Aud[0] + } + return "" +} diff --git a/internal/auth/codex/openai_auth.go b/internal/auth/codex/openai_auth.go index 64bc00a67d..d85abd32b4 100644 --- a/internal/auth/codex/openai_auth.go +++ b/internal/auth/codex/openai_auth.go @@ -166,15 +166,17 @@ func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, } // RefreshTokens refreshes an access token using a refresh token. -// This method is called when an access token has expired. It makes a request to the -// token endpoint to obtain a new set of tokens. -func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) { +// clientID overrides the default hardcoded ClientID when non-empty. +func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken, clientID string) (*CodexTokenData, error) { if refreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + if clientID == "" { + clientID = ClientID + } data := url.Values{ - "client_id": {ClientID}, + "client_id": {clientID}, "grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "scope": {"openid profile email"}, @@ -257,9 +259,8 @@ func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStora } // RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism. -// It attempts to refresh the tokens up to a specified maximum number of retries, -// with an exponential backoff strategy to handle transient network errors. -func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) { +// clientID overrides the default hardcoded ClientID when non-empty. +func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken, clientID string, maxRetries int) (*CodexTokenData, error) { var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { @@ -272,7 +273,7 @@ func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken str } } - tokenData, err := o.RefreshTokens(ctx, refreshToken) + tokenData, err := o.RefreshTokens(ctx, refreshToken, clientID) if err == nil { return tokenData, nil } diff --git a/internal/auth/codex/openai_auth_test.go b/internal/auth/codex/openai_auth_test.go index 3327eb4ab5..7cae7ddcda 100644 --- a/internal/auth/codex/openai_auth_test.go +++ b/internal/auth/codex/openai_auth_test.go @@ -31,7 +31,7 @@ func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) { }, } - _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3) + _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", "app_EMoamEEZ73f0CkXaXp7hrann", 3) if err == nil { t.Fatalf("expected error for non-retryable refresh failure") } diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 4fb2291900..5c9142beeb 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -562,17 +562,29 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (* if auth == nil { return nil, statusErr{code: 500, msg: "codex executor: auth is nil"} } - var refreshToken string + var refreshToken, clientID string if auth.Metadata != nil { if v, ok := auth.Metadata["refresh_token"].(string); ok && v != "" { refreshToken = v } + // Prefer explicit client_id stored in metadata + if v, ok := auth.Metadata["client_id"].(string); ok && v != "" { + clientID = v + } } if refreshToken == "" { return auth, nil } + // Fall back to parsing client_id from id_token.aud[0] + if clientID == "" { + if idTokenRaw, ok := auth.Metadata["id_token"].(string); ok && idTokenRaw != "" { + if claims, err := codexauth.ParseJWTToken(idTokenRaw); err == nil && claims != nil { + clientID = claims.GetClientID() + } + } + } svc := codexauth.NewCodexAuth(e.cfg) - td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3) + td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, clientID, 3) if err != nil { return nil, err } diff --git a/internal/runtime/executor/codex_executor_account_id_test.go b/internal/runtime/executor/codex_executor_account_id_test.go new file mode 100644 index 0000000000..fb01187bb4 --- /dev/null +++ b/internal/runtime/executor/codex_executor_account_id_test.go @@ -0,0 +1,198 @@ +package executor + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/google/uuid" + tls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" +) + +// utlsTransport is a minimal Chrome-fingerprint TLS transport for test use. +// Supports HTTP CONNECT proxy tunneling. +type utlsTransport struct { + proxyURL string +} + +func newUtlsTransport(proxyURL string) *utlsTransport { + return &utlsTransport{proxyURL: proxyURL} +} + +func (t *utlsTransport) dial(addr string) (net.Conn, error) { + if t.proxyURL == "" { + return net.Dial("tcp", addr) + } + u, err := url.Parse(t.proxyURL) + if err != nil { + return nil, fmt.Errorf("parse proxy url: %w", err) + } + conn, err := net.Dial("tcp", u.Host) + if err != nil { + return nil, fmt.Errorf("connect to proxy: %w", err) + } + // HTTP CONNECT tunnel + req, _ := http.NewRequest(http.MethodConnect, "http://"+addr, nil) + req.Host = addr + if err = req.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write CONNECT: %w", err) + } + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read CONNECT response: %w", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status) + } + return conn, nil +} + +func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.URL.Hostname() + addr := host + ":443" + + conn, err := t.dial(addr) + if err != nil { + return nil, fmt.Errorf("dial: %w", err) + } + + tlsConn := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloChrome_Auto) + if err = tlsConn.Handshake(); err != nil { + conn.Close() + return nil, fmt.Errorf("tls handshake: %w", err) + } + + tr := &http2.Transport{} + h2Conn, err := tr.NewClientConn(tlsConn) + if err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2 conn: %w", err) + } + + return h2Conn.RoundTrip(req) +} + +// planTypePriority returns a numeric priority for a plan_type string. +// Higher value means higher priority: team > plus > free > others. +func planTypePriority(planType string) int { + switch strings.ToLower(planType) { + case "team": + return 3 + case "plus": + return 2 + case "free": + return 1 + default: + return 0 + } +} + +// pickBestAccountID selects the best account_id from the $.accounts map returned by +// the accounts/check API. Priority: team > plus > free > any other. +// Returns empty string if no accounts are found. +func pickBestAccountID(accounts map[string]any) string { + bestID := "" + bestPriority := -1 + for accountID, v := range accounts { + info, ok := v.(map[string]any) + if !ok { + continue + } + account, ok := info["account"].(map[string]any) + if !ok { + continue + } + planType, _ := account["plan_type"].(string) + p := planTypePriority(planType) + if p > bestPriority { + bestPriority = p + bestID = accountID + } + } + return bestID +} + +// TestCodexAccountCheck tests GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27 +// using a real access_token. Set CODEX_ACCESS_TOKEN (and optionally CODEX_PROXY_URL) to run. +// +// Example: +// +// CODEX_ACCESS_TOKEN=eyJ... go test ./internal/runtime/executor/... -run TestCodexAccountCheck -v +// CODEX_ACCESS_TOKEN=eyJ... CODEX_PROXY_URL=http://127.0.0.1:7890 go test ./internal/runtime/executor/... -run TestCodexAccountCheck -v +func TestCodexAccountCheck(t *testing.T) { + accessToken := os.Getenv("CODEX_ACCESS_TOKEN") + if accessToken == "" { + t.Skip("skipping: CODEX_ACCESS_TOKEN not set") + } + proxyURL := os.Getenv("CODEX_PROXY_URL") + deviceID := uuid.NewString() + targetURL := "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27?timezone_offset_min=-480" + + req, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + + req.Header.Set("accept", "*/*") + req.Header.Set("accept-language", "zh-HK,zh;q=0.9,en-US;q=0.8,en;q=0.7") + req.Header.Set("authorization", "Bearer "+strings.TrimSpace(accessToken)) + req.Header.Set("oai-device-id", deviceID) + req.Header.Set("oai-language", "zh-HK") + req.Header.Set("referer", "https://chatgpt.com/") + req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36") + req.Header.Set("sec-ch-ua", `"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("sec-fetch-dest", "empty") + req.Header.Set("sec-fetch-mode", "cors") + req.Header.Set("sec-fetch-site", "same-origin") + req.Header.Set("priority", "u=1, i") + + client := &http.Client{ + Transport: newUtlsTransport(proxyURL), + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + + t.Logf("status: %d", resp.StatusCode) + t.Logf("device_id: %s", deviceID) + t.Logf("response: %s", string(body)) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + return + } + + // Parse response and pick the best account_id + var parsed map[string]any + if err = json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if accounts, ok := parsed["accounts"].(map[string]any); ok { + bestID := pickBestAccountID(accounts) + t.Logf("best_account_id (team>plus>free): %s", bestID) + } else { + t.Logf("no $.accounts map found in response") + } +}