diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aa5398dd..eabd9f35 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -792,11 +792,14 @@ dependencies = [ name = "codex-login" version = "0.0.0" dependencies = [ + "base64 0.22.1", "chrono", + "pretty_assertions", "reqwest", "serde", "serde_json", "tempfile", + "thiserror 2.0.12", "tokio", ] diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 60eb9224..2148e874 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -290,13 +290,10 @@ async fn chatgpt_auth_sends_correct_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn( - config, - Some(auth_from_token("Access Token".to_string())), - ctrl_c.clone(), - ) - .await - .unwrap(); + let CodexSpawnOk { codex, .. } = + Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone()) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -541,13 +538,10 @@ async fn env_var_overrides_loaded_auth() { config.model_provider = provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn( - config, - Some(auth_from_token("Default Access Token".to_string())), - ctrl_c.clone(), - ) - .await - .unwrap(); + let CodexSpawnOk { codex, .. } = + Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone()) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -561,7 +555,7 @@ async fn env_var_overrides_loaded_auth() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } -fn auth_from_token(id_token: String) -> CodexAuth { +fn create_dummy_codex_auth() -> CodexAuth { CodexAuth::new( None, AuthMode::ChatGPT, @@ -569,7 +563,7 @@ fn auth_from_token(id_token: String) -> CodexAuth { Some(AuthDotJson { openai_api_key: None, tokens: Some(TokenData { - id_token, + id_token: Default::default(), access_token: "Access Token".to_string(), refresh_token: "test".to_string(), account_id: Some("account_id".to_string()), diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 650291b3..a290c01e 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -7,10 +7,12 @@ version = { workspace = true } workspace = true [dependencies] +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +thiserror = "2.0.12" tokio = { version = "1", features = [ "io-std", "macros", @@ -20,4 +22,5 @@ tokio = { version = "1", features = [ ] } [dev-dependencies] +pretty_assertions = "1.4.1" tempfile = "3" diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index f35191ce..a52e1056 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -20,6 +20,11 @@ use std::sync::Mutex; use std::time::Duration; use tokio::process::Command; +pub use crate::token_data::TokenData; +use crate::token_data::parse_id_token; + +mod token_data; + const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -182,7 +187,7 @@ pub fn load_auth(codex_home: &Path, include_env_var: bool) -> std::io::Result PathBuf { +pub fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } @@ -332,7 +337,7 @@ async fn update_tokens( let mut auth_dot_json = try_read_auth_json(auth_file)?; let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = id_token.to_string(); + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; if let Some(access_token) = access_token { tokens.access_token = access_token.to_string(); } @@ -403,22 +408,12 @@ pub struct AuthDotJson { pub last_refresh: Option>, } -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] -pub struct TokenData { - /// This is a JWT. - pub id_token: String, - - /// This is a JWT. - pub access_token: String, - - pub refresh_token: String, - - pub account_id: Option, -} - #[cfg(test)] mod tests { use super::*; + use crate::token_data::IdTokenInfo; + use base64::Engine; + use pretty_assertions::assert_eq; use tempfile::tempdir; #[test] @@ -446,10 +441,35 @@ mod tests { } #[tokio::test] - #[expect(clippy::unwrap_used)] + #[expect(clippy::expect_used, clippy::unwrap_used)] async fn loads_token_data_from_auth_json() { let dir = tempdir().unwrap(); let auth_file = dir.path().join("auth.json"); + // Create a minimal valid JWT for the id_token field. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": { + "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", + "chatgpt_plan_type": "pro", + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + } + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); std::fs::write( auth_file, format!( @@ -457,30 +477,68 @@ mod tests { {{ "OPENAI_API_KEY": null, "tokens": {{ - "id_token": "test-id-token", + "id_token": "{fake_jwt}", "access_token": "test-access-token", "refresh_token": "test-refresh-token" }}, - "last_refresh": "{}" + "last_refresh": "2025-08-06T20:41:36.232376Z" }} "#, - Utc::now().to_rfc3339() ), ) .unwrap(); - let auth = load_auth(dir.path(), false).unwrap().unwrap(); - assert_eq!(auth.mode, AuthMode::ChatGPT); - assert_eq!(auth.api_key, None); + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file, + } = load_auth(dir.path(), false).unwrap().unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + assert_eq!(dir.path().join("auth.json"), auth_file); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( - auth.get_token_data().await.unwrap(), - TokenData { - id_token: "test-id-token".to_string(), - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - } - ); + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some("pro".to_string()), + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339("2025-08-06T20:41:36.232376Z") + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + #[test] + #[expect(clippy::expect_used, clippy::unwrap_used)] + fn id_token_info_handles_missing_fields() { + // Payload without email or plan should yield None values. + let header = serde_json::json!({"alg": "none", "typ": "JWT"}); + let payload = serde_json::json!({"sub": "123"}); + let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); + let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.chatgpt_plan_type.is_none()); } #[tokio::test] diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs new file mode 100644 index 00000000..55b51b9d --- /dev/null +++ b/codex-rs/login/src/token_data.rs @@ -0,0 +1,117 @@ +use base64::Engine; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] +pub struct TokenData { + /// Flat info parsed from the JWT in auth.json. + #[serde(deserialize_with = "deserialize_id_token")] + pub id_token: IdTokenInfo, + + /// This is a JWT. + pub access_token: String, + + pub refresh_token: String, + + pub account_id: Option, +} + +/// Flat subset of useful claims in id_token from auth.json. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct IdTokenInfo { + pub email: Option, + /// The ChatGPT subscription plan type + /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). + /// (Note: ae has not verified that those are the exact values.) + pub chatgpt_plan_type: Option, +} + +#[derive(Deserialize)] +struct IdClaims { + #[serde(default)] + email: Option, + #[serde(rename = "https://api.openai.com/auth", default)] + auth: Option, +} + +#[derive(Deserialize)] +struct AuthClaims { + #[serde(default)] + chatgpt_plan_type: Option, +} + +#[derive(Debug, Error)] +pub enum IdTokenInfoError { + #[error("invalid ID token format")] + InvalidFormat, + #[error(transparent)] + Base64(#[from] base64::DecodeError), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub(crate) fn parse_id_token(id_token: &str) -> Result { + // JWT format: header.payload.signature + let mut parts = id_token.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => return Err(IdTokenInfoError::InvalidFormat), + }; + + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; + let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; + + Ok(IdTokenInfo { + email: claims.email, + chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type), + }) +} + +fn deserialize_id_token<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_id_token(&s).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[test] + #[expect(clippy::expect_used, clippy::unwrap_used)] + fn id_token_info_parses_email_and_plan() { + // Build a fake JWT with a URL-safe base64 payload containing email and plan. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro" + } + }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert_eq!(info.email.as_deref(), Some("user@example.com")); + assert_eq!(info.chatgpt_plan_type.as_deref(), Some("pro")); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 6beb7975..d7a06fa5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; +use codex_login::get_auth_file; +use codex_login::try_read_auth_json; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -469,8 +471,38 @@ impl HistoryCell { lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); } - // Token usage lines.push(Line::from("")); + + // Auth + let auth_file = get_auth_file(&config.codex_home); + if let Ok(auth) = try_read_auth_json(&auth_file) { + if auth.tokens.as_ref().is_some() { + lines.push(Line::from("signed in with chatgpt".bold())); + + if let Some(tokens) = auth.tokens.as_ref() { + let info = tokens.id_token.clone(); + if let Some(email) = info.email { + lines.push(Line::from(vec![" login: ".bold(), email.into()])); + } + + match auth.openai_api_key.as_deref() { + Some(key) if !key.is_empty() => { + lines.push(Line::from(" using api key")); + } + _ => { + let plan_text = info + .chatgpt_plan_type + .unwrap_or_else(|| "Unknown".to_string()); + lines.push(Line::from(vec![" plan: ".bold(), plan_text.into()])); + } + } + } + + lines.push(Line::from("")); + } + } + + // Token usage lines.push(Line::from("token usage".bold())); lines.push(Line::from(vec![ " input: ".bold(),