Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions codex-rs/login/src/auth/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,40 @@ fn refresh_token_endpoint() -> String {
}

impl AuthDotJson {
pub fn account_email(&self) -> Option<String> {
match self.resolved_mode() {
ApiAuthMode::AgentIdentity => self
.agent_identity
.as_deref()
.and_then(|jwt| AgentIdentityAuthRecord::from_agent_identity_jwt(jwt).ok())
.map(|record| record.email),
ApiAuthMode::PersonalAccessToken => None,
ApiAuthMode::ApiKey | ApiAuthMode::Chatgpt | ApiAuthMode::ChatgptAuthTokens => self
.tokens
.as_ref()
.and_then(|tokens| tokens.id_token.email.clone()),
}
}

pub fn account_id(&self) -> Option<String> {
match self.resolved_mode() {
ApiAuthMode::AgentIdentity => self
.agent_identity
.as_deref()
.and_then(|jwt| AgentIdentityAuthRecord::from_agent_identity_jwt(jwt).ok())
.map(|record| record.account_id),
ApiAuthMode::PersonalAccessToken => None,
ApiAuthMode::ApiKey | ApiAuthMode::Chatgpt | ApiAuthMode::ChatgptAuthTokens => {
self.tokens.as_ref().and_then(|tokens| {
tokens
.account_id
.clone()
.or(tokens.id_token.chatgpt_account_id.clone())
})
}
}
}

fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result<Self> {
let Some(chatgpt_metadata) = external.chatgpt_metadata() else {
return Err(std::io::Error::other(
Expand Down
28 changes: 28 additions & 0 deletions codex-rs/login/src/auth/storage_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,34 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson {
}
}

#[test]
fn auth_dot_json_exposes_agent_identity_account_metadata() {
let agent_identity = jwt_with_payload(json!({
"iss": "https://chatgpt.com/codex-backend/agent-identity",
"aud": "codex-app-server",
"iat": 1_700_000_000usize,
"exp": 4_000_000_000usize,
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"email": "agent@example.com",
"plan_type": "pro",
"chatgpt_account_is_fedramp": false,
}));
let auth = AuthDotJson {
auth_mode: Some(AuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(agent_identity),
personal_access_token: None,
};

assert_eq!(auth.account_email().as_deref(), Some("agent@example.com"));
assert_eq!(auth.account_id().as_deref(), Some("account-id"));
}

fn jwt_with_payload(payload: serde_json::Value) -> String {
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
Expand Down
37 changes: 36 additions & 1 deletion codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::bottom_pane::slash_commands::SlashCommandItem;
use crate::bottom_pane::slash_commands::find_slash_command;
use crate::goal_display::GOAL_USAGE;
use codex_config::types::AuthCredentialsStoreMode;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
Expand Down Expand Up @@ -51,11 +52,15 @@ impl ChatWidget {
let current_auth_home = self.config.auth_home.to_path_buf();
let default_auth_home = self.config.codex_home.to_path_buf();
let codex_home = self.config.codex_home.to_path_buf();
let default_description = default_login_profile_description(
&default_auth_home,
self.config.cli_auth_credentials_store_mode,
);
let add_command = "/login add ".to_string();
let mut items = Vec::with_capacity(profiles.len() + 2);
items.push(SelectionItem {
name: "default".to_string(),
description: Some("Use the default Codex Lab login".to_string()),
description: Some(default_description),
is_current: current_auth_home == default_auth_home,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::SwitchAuthProfile {
Expand Down Expand Up @@ -1232,3 +1237,33 @@ fn login_profile_description(profile: &codex_login::AuthProfileEntry) -> String
details.join(" - ")
}
}

fn default_login_profile_description(
codex_home: &std::path::Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> String {
match codex_login::load_auth_dot_json(codex_home, auth_credentials_store_mode) {
Ok(Some(auth)) => auth_dot_json_login_description(&auth),
Ok(None) => "Use the default Codex Lab login".to_string(),
Err(err) => format!("Default login status unavailable: {err}"),
}
}

fn auth_dot_json_login_description(auth: &codex_login::AuthDotJson) -> String {
if auth.openai_api_key.is_some() {
return "API key login".to_string();
}

let mut details = Vec::new();
if let Some(email) = auth.account_email() {
details.push(email);
}
if let Some(account_id) = auth.account_id() {
details.push(account_id);
}
if !details.is_empty() {
return details.join(" - ");
}

"Use the default Codex Lab login".to_string()
}
60 changes: 60 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
use super::*;
use crate::app_event::AuthProfileSelection;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthDotJson;
use codex_login::save_auth;
use codex_login::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use serial_test::serial;

fn force_pet_image_support(chat: &mut ChatWidget) {
Expand Down Expand Up @@ -36,6 +45,50 @@ fn fast_tier_command() -> ServiceTierCommand {
}
}

fn fake_jwt(email: &str, account_id: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}

let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": email,
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}

fn write_chatgpt_auth(codex_home: &std::path::Path, email: &str, account_id: &str) {
let id_token = fake_jwt(email, account_id);
let access_token = fake_jwt(email, account_id);
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_login::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some(account_id.to_string()),
}),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
};
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File).expect("chatgpt auth saved");
}

fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
if let Some(message) = message {
complete_assistant_message(
Expand Down Expand Up @@ -120,6 +173,11 @@ async fn service_tier_commands_lowercase_catalog_names() {
#[tokio::test]
async fn login_slash_command_opens_profile_picker() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
write_chatgpt_auth(
&chat.config.codex_home,
"default@example.com",
"account-default",
);
codex_login::record_auth_profile_login(
&chat.config.codex_home,
"work",
Expand All @@ -133,6 +191,8 @@ async fn login_slash_command_opens_profile_picker() {
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(popup.contains("Choose Login"));
assert!(popup.contains("default"));
assert!(popup.contains("default@example.com"));
assert!(popup.contains("account-default"));
assert!(popup.contains("work"));
assert!(popup.contains("me@example.com"));
assert!(popup.contains("Add login..."));
Expand Down
Loading