Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-free-login-status.md
Original file line number Diff line number Diff line change
@@ -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,已经登录过的账号会在下次刷新时自动恢复。
75 changes: 58 additions & 17 deletions internal/providers/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand Down
69 changes: 68 additions & 1 deletion internal/providers/codex/s04_interfaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codex

import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading