diff --git a/changelog/unreleased/codex-free-login-status.md b/changelog/unreleased/codex-free-login-status.md new file mode 100644 index 0000000..44e94cf --- /dev/null +++ b/changelog/unreleased/codex-free-login-status.md @@ -0,0 +1,7 @@ +### English + +- Codex free accounts no longer stay on login failed after a successful sign-in. The console now reads the ChatGPT account id from the login token, and an account that already signed in recovers on the next refresh. + +### 中文 + +- Codex 免费账号登录成功后不再一直显示登录失败。控制台会从登录令牌里读出 ChatGPT 账号 ID,已经登录过的账号会在下次刷新时自动恢复。 diff --git a/internal/providers/codex/client.go b/internal/providers/codex/client.go index 57c9021..fb95c6e 100644 --- a/internal/providers/codex/client.go +++ b/internal/providers/codex/client.go @@ -406,11 +406,29 @@ func parseTokenResponse(payload []byte) (Credential, error) { if tok.ExpiresIn > 0 { credential.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix() } - if claims := parseJWTClaims(tok.IDToken); claims != nil { - credential.AccountID = firstNonEmpty(claims.AccountID, claims.ChatGPTAccountID) + applyJWTIdentity(&credential, tok.IDToken) + return credential, nil +} + +// applyJWTIdentity fills AccountID and Email from an id_token. OpenAI puts +// the ChatGPT account id under https://api.openai.com/auth.chatgpt_account_id, +// not a top-level account_id. Free accounts only carry the nested claim, so +// reading the top-level field alone leaves AccountID empty and the console +// treats a usable login as incomplete. +func applyJWTIdentity(credential *Credential, token string) { + if credential == nil { + return + } + claims := parseJWTClaims(token) + if claims == nil { + return + } + if accountID := firstNonEmpty(claims.ChatGPTAccountID, claims.AccountID); accountID != "" { + credential.AccountID = accountID + } + if claims.Email != "" { credential.Email = claims.Email } - return credential, nil } type jwtClaims struct { @@ -429,23 +447,35 @@ func parseJWTClaims(token string) *jwtClaims { return nil } var claims jwtClaims - // account_id can nest under https://api.openai.com/auth. + if json.Unmarshal(payload, &claims) != nil { + return nil + } + // OpenAI nests the ChatGPT identity under https://api.openai.com/auth. + // A free account has chatgpt_account_id there and no top-level account_id. var raw map[string]json.RawMessage if json.Unmarshal(payload, &raw) != nil { - return nil + return &claims } - _ = json.Unmarshal(payload, &claims) - if claims.AccountID == "" { - for key, value := range raw { - if !strings.HasSuffix(key, "auth") { - continue - } - var nested struct { - AccountID string `json:"account_id"` - } - if json.Unmarshal(value, &nested) == nil && nested.AccountID != "" { - claims.AccountID = nested.AccountID - } + for key, value := range raw { + if !strings.HasSuffix(key, "/auth") && !strings.HasSuffix(key, "auth") { + continue + } + var nested struct { + AccountID string `json:"account_id"` + ChatGPTAccountID string `json:"chatgpt_account_id"` + Email string `json:"email"` + } + if json.Unmarshal(value, &nested) != nil { + continue + } + if claims.ChatGPTAccountID == "" { + claims.ChatGPTAccountID = nested.ChatGPTAccountID + } + if claims.AccountID == "" { + claims.AccountID = firstNonEmpty(nested.AccountID, nested.ChatGPTAccountID) + } + if claims.Email == "" { + claims.Email = nested.Email } } return &claims @@ -465,6 +495,17 @@ func (c *Client) credential(ctx context.Context, accountID string) (Credential, if err != nil { return Credential{}, err } + // Logins that missed the nested chatgpt_account_id stored a usable token + // with an empty AccountID. Probe then reported login failure even though + // chat still works. Recover the id from the stored id_token and persist it. + if strings.TrimSpace(credential.AccountID) == "" && strings.TrimSpace(credential.IDToken) != "" { + applyJWTIdentity(&credential, credential.IDToken) + if strings.TrimSpace(credential.AccountID) != "" { + if encoded, encErr := credential.Encode(); encErr == nil { + _ = c.store.SaveCredentialPayload(ctx, accountID, CredentialFormat, encoded) + } + } + } if !credential.needsRefresh(time.Now()) { return credential, nil } diff --git a/internal/providers/codex/s04_interfaces_test.go b/internal/providers/codex/s04_interfaces_test.go index e996c72..1992b20 100644 --- a/internal/providers/codex/s04_interfaces_test.go +++ b/internal/providers/codex/s04_interfaces_test.go @@ -2,6 +2,7 @@ package codex import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -28,7 +29,13 @@ func (s testStore) LoadCredentialPayload(_ context.Context, accountID string) (s } return CredentialFormat, payload, nil } -func (testStore) SaveCredentialPayload(context.Context, string, string, []byte) error { return nil } +func (s testStore) SaveCredentialPayload(_ context.Context, accountID, _ string, payload []byte) error { + if s.items == nil { + return nil + } + s.items[accountID] = payload + return nil +} func (testStore) Observe(context.Context, string, string, string, string, string) error { return nil } @@ -84,6 +91,66 @@ func TestCredentialRoundTrip(t *testing.T) { } } +func unsignedJWT(claims map[string]any) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload, _ := json.Marshal(claims) + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".sig" +} + +func TestParseTokenResponseReadsNestedChatGPTAccount(t *testing.T) { + idToken := unsignedJWT(map[string]any{ + "email": "free@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_free", + "chatgpt_plan_type": "free", + }, + }) + body, _ := json.Marshal(map[string]any{ + "access_token": "at", + "refresh_token": "rt", + "id_token": idToken, + "expires_in": 3600, + }) + cred, err := parseTokenResponse(body) + if err != nil { + t.Fatal(err) + } + if cred.AccountID != "acct_free" || cred.Email != "free@example.com" || !cred.Ready() { + t.Fatalf("credential %+v", cred) + } +} + +func TestProbeRecoversMissingAccountIDFromIDToken(t *testing.T) { + idToken := unsignedJWT(map[string]any{ + "email": "free@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_free", + "chatgpt_plan_type": "free", + }, + }) + payload, _ := json.Marshal(Credential{ + IDToken: idToken, + AccessToken: "at", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }) + store := testStore{items: map[string][]byte{"acc-1": payload}} + health, err := NewClient(store).Probe(context.Background(), "acc-1") + if err != nil { + t.Fatal(err) + } + if !health.Ready || health.LastError != "" || health.UID != "free@example.com" { + t.Fatalf("health %+v", health) + } + recovered, err := DecodeCredential(store.items["acc-1"]) + if err != nil { + t.Fatal(err) + } + if recovered.AccountID != "acct_free" { + t.Fatalf("account id not persisted: %+v", recovered) + } +} + func TestDecodeCredentialNestedTokenData(t *testing.T) { payload := []byte(`{"token_data":{"access_token":"at","refresh_token":"rt","account_id":"acct","email":"e@x","expired":"2027-01-01T00:00:00Z"}}`) cred, err := DecodeCredential(payload)