Handle missing plan info for ChatGPT accounts (#13072)

Addresses https://github.com/openai/codex/issues/13007 and
https://github.com/openai/codex/issues/12170

There are situations where the ChatGPT auth backend might return a JWT
that contains no plan information. Most code paths already handle this
case well, but the internal implementation of the "account/read" app
server call was failing in this case (returning an error rather than
properly returning None for the plan).

This resulted in a situation where users needed to log in every time the
extension or app started even if they successfully logged in the last
time.

Summary
- allow ChatGPT-authenticated accounts to fall back to
`AccountPlanType::Unknown` when the token omits the plan claim
- add regression coverage in `app-server/tests/suite/v2/account.rs` to
confirm `account/read` returns `plan_type: Unknown` when the claim is
absent
- ensure the Rust auth helpers and fixtures treat missing plan claims as
Optional and default to `Unknown`
This commit is contained in:
Eric Traut 2026-02-27 16:51:21 -08:00 committed by GitHub
parent 61c42396ab
commit ff5cbfd7d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 84 additions and 14 deletions

View file

@ -1227,3 +1227,45 @@ async fn get_account_with_chatgpt() -> Result<()> {
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").email("user@example.com"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Unknown,
}),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}

View file

@ -266,6 +266,8 @@ impl CodexAuth {
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// mapped from the ID token's internal plan value. Prefer this when you
/// need to make UI or product decisions based on the user's subscription.
/// When ChatGPT auth is active but the token omits the plan claim, report
/// `Unknown` instead of treating the account as invalid.
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
let map_known = |kp: &InternalKnownPlan| match kp {
InternalKnownPlan::Free => AccountPlanType::Free,
@ -278,12 +280,15 @@ impl CodexAuth {
InternalKnownPlan::Edu => AccountPlanType::Edu,
};
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_plan_type)
.map(|pt| match pt {
InternalPlanType::Known(k) => map_known(&k),
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
})
self.get_current_token_data().map(|t| {
t.id_token
.chatgpt_plan_type
.map(|pt| match pt {
InternalPlanType::Known(k) => map_known(&k),
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
})
.unwrap_or(AccountPlanType::Unknown)
})
}
/// Returns `None` if `is_chatgpt_auth()` is false.
@ -1377,7 +1382,7 @@ mod tests {
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
@ -1447,7 +1452,7 @@ mod tests {
let fake_jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
@ -1528,7 +1533,7 @@ mod tests {
struct AuthFileParams {
openai_api_key: Option<String>,
chatgpt_plan_type: String,
chatgpt_plan_type: Option<String>,
chatgpt_account_id: Option<String>,
}
@ -1545,11 +1550,14 @@ mod tests {
typ: "JWT",
};
let mut auth_payload = serde_json::json!({
"chatgpt_plan_type": params.chatgpt_plan_type,
"chatgpt_user_id": "user-12345",
"user_id": "user-12345",
});
if let Some(chatgpt_plan_type) = params.chatgpt_plan_type {
auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type);
}
if let Some(chatgpt_account_id) = params.chatgpt_account_id {
let org_value = serde_json::Value::String(chatgpt_account_id);
auth_payload["chatgpt_account_id"] = org_value;
@ -1650,7 +1658,7 @@ mod tests {
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_another_org".to_string()),
},
codex_home.path(),
@ -1675,7 +1683,7 @@ mod tests {
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some("org_mine".to_string()),
},
codex_home.path(),
@ -1729,7 +1737,7 @@ mod tests {
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
@ -1749,7 +1757,27 @@ mod tests {
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "mystery-tier".to_string(),
chatgpt_plan_type: Some("mystery-tier".to_string()),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
}
#[test]
fn missing_plan_type_maps_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: None,
chatgpt_account_id: None,
},
codex_home.path(),