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:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
117
codex-rs/login/src/token_data.rs
Normal file
117
codex-rs/login/src/token_data.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user