Compare commits

...

3 Commits

Author SHA1 Message Date
nicholasclark-openai
ed7a4ede6e Merge branch 'main' into nicholasclark/missing-id-token 2026-04-09 16:04:57 -07:00
nicholasclark-openai
f88d211d7c Simplify id token parser test
Collapse the missing and empty id_token parser coverage into one focused missing-field regression test.

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 12:59:55 -07:00
nicholasclark-openai
cc1f80d831 Handle missing ChatGPT id token
Allow stored ChatGPT auth tokens to load when the optional id_token value is absent or empty, preserving access and refresh tokens so Codex can start without requiring a relogin.

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 11:32:56 -07:00
3 changed files with 74 additions and 2 deletions

View File

@@ -164,6 +164,49 @@ async fn loads_api_key_from_auth_json() {
assert!(auth.get_token_data().is_err());
}
#[tokio::test]
#[serial(codex_api_key)]
async fn loads_chatgpt_auth_without_id_token() {
let dir = tempdir().unwrap();
let auth_file = dir.path().join("auth.json");
std::fs::write(
auth_file,
serde_json::to_string_pretty(&json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"account_id": "account-id"
},
"last_refresh": Utc::now(),
}))
.unwrap(),
)
.unwrap();
let auth = super::load_auth(
dir.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
)
.unwrap()
.unwrap();
assert_eq!(auth.auth_mode(), AuthMode::Chatgpt);
assert_eq!(auth.get_account_id().as_deref(), Some("account-id"));
assert_eq!(auth.get_account_email(), None);
assert_eq!(
auth.get_token_data().unwrap(),
TokenData {
id_token: IdTokenInfo::default(),
access_token: "test-access-token".to_string(),
refresh_token: "test-refresh-token".to_string(),
account_id: Some("account-id".to_string()),
}
);
}
#[test]
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
let dir = tempdir()?;

View File

@@ -11,8 +11,10 @@ use thiserror::Error;
pub struct TokenData {
/// Flat info parsed from the JWT in auth.json.
#[serde(
default,
deserialize_with = "deserialize_id_token",
serialize_with = "serialize_id_token"
serialize_with = "serialize_id_token",
skip_serializing_if = "IdTokenInfo::is_empty"
)]
pub id_token: IdTokenInfo,
@@ -40,6 +42,10 @@ pub struct IdTokenInfo {
}
impl IdTokenInfo {
fn is_empty(&self) -> bool {
self.raw_jwt.is_empty()
}
pub fn get_chatgpt_plan_type(&self) -> Option<String> {
self.chatgpt_plan_type.as_ref().map(|t| match t {
PlanType::Known(plan) => plan.display_name().to_string(),
@@ -154,7 +160,10 @@ fn deserialize_id_token<'de, D>(deserializer: D) -> Result<IdTokenInfo, D::Error
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let s = Option::<String>::deserialize(deserializer)?;
let Some(s) = s.filter(|s| !s.is_empty()) else {
return Ok(IdTokenInfo::default());
};
parse_chatgpt_jwt_claims(&s).map_err(serde::de::Error::custom)
}

View File

@@ -116,6 +116,26 @@ fn id_token_info_handles_missing_fields() {
assert!(info.get_chatgpt_plan_type().is_none());
}
#[test]
fn token_data_handles_missing_id_token() {
let tokens: TokenData = serde_json::from_value(serde_json::json!({
"access_token": "access-token",
"refresh_token": "refresh-token",
"account_id": "account-id",
}))
.expect("token data should parse");
assert_eq!(
tokens,
TokenData {
id_token: IdTokenInfo::default(),
access_token: "access-token".to_string(),
refresh_token: "refresh-token".to_string(),
account_id: Some("account-id".to_string()),
}
);
}
#[test]
fn jwt_expiration_parses_exp_claim() {
let fake_jwt = fake_jwt(serde_json::json!({