feat: parse info from auth.json and show in /status (#1923)

- `/status` renders
    ```
    signed in with chatgpt
      login: example@example.com
      plan: plus
    ```
- Setup for using this info in a few more places.

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
ae
2025-08-07 01:27:45 -07:00
committed by GitHub
parent 6d19b73edf
commit 0334476894
6 changed files with 254 additions and 47 deletions

3
codex-rs/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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()),

View File

@@ -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"

View File

@@ -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<Op
}))
}
fn get_auth_file(codex_home: &Path) -> 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<DateTime<Utc>>,
}
#[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<String>,
}
#[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]

View File

@@ -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<String>,
}
/// Flat subset of useful claims in id_token from auth.json.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
pub struct IdTokenInfo {
pub email: Option<String>,
/// 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<String>,
}
#[derive(Deserialize)]
struct IdClaims {
#[serde(default)]
email: Option<String>,
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_plan_type: Option<String>,
}
#[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<IdTokenInfo, IdTokenInfoError> {
// 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<IdTokenInfo, D::Error>
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"));
}
}

View File

@@ -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(),