Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/auth/codex/jwt_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
17 changes: 9 additions & 8 deletions internal/auth/codex/openai_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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++ {
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/codex/openai_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
16 changes: 14 additions & 2 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
198 changes: 198 additions & 0 deletions internal/runtime/executor/codex_executor_account_id_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading