From 2fea766d3c13a394f9ac3789b03915f3349240aa Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Tue, 16 Jun 2026 20:20:02 -0400 Subject: [PATCH] Add auth profile login commands --- codex-rs/cli/src/lib.rs | 2 +- codex-rs/cli/src/login.rs | 267 ++++++++++++++++-------- codex-rs/cli/src/main.rs | 83 +++++++- codex-rs/login/src/auth_profiles.rs | 309 ++++++++++++++++++++++++++++ codex-rs/login/src/lib.rs | 10 + 5 files changed, 584 insertions(+), 87 deletions(-) create mode 100644 codex-rs/login/src/auth_profiles.rs diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 6b7db15a4421..9a6e880936b3 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -13,12 +13,12 @@ pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows_sandbox; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; +pub use login::run_login_profiles; pub use login::run_login_status; pub use login::run_login_with_access_token; pub use login::run_login_with_api_key; pub use login::run_login_with_chatgpt; pub use login::run_login_with_device_code; -pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; // These command structs share common sandbox options, but remain separate diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b3fc1bd544e4..97905359ed78 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,9 +13,12 @@ use codex_core::config::Config; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; +use codex_login::list_auth_profiles; use codex_login::login_with_access_token; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; +use codex_login::profile_home; +use codex_login::record_auth_profile_login; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; @@ -39,6 +42,85 @@ const ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE: &str = "Access token login is disabled. Use API key login instead."; const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; +struct LoginTarget { + codex_home: PathBuf, + profile: Option, +} + +impl LoginTarget { + fn is_default(&self) -> bool { + self.profile.is_none() + } + + fn label(&self) -> String { + self.profile + .as_ref() + .map(|profile| format!("profile `{profile}`")) + .unwrap_or_else(|| "default profile".to_string()) + } +} + +fn profile_suffix(target: &LoginTarget) -> String { + if target.is_default() { + String::new() + } else { + format!(" for {}", target.label()) + } +} + +fn resolve_login_target(config: &Config, profile: Option) -> std::io::Result { + let codex_home = match profile.as_deref() { + Some(profile_name) => profile_home(&config.codex_home, profile_name)?, + None => config.codex_home.to_path_buf(), + }; + Ok(LoginTarget { + codex_home, + profile, + }) +} + +fn note_profile_login( + config: &Config, + target: &LoginTarget, + auth: &CodexAuth, +) -> std::io::Result<()> { + let Some(profile) = target.profile.as_deref() else { + return Ok(()); + }; + record_auth_profile_login( + &config.codex_home, + profile, + auth.get_account_id(), + auth.get_account_email(), + )?; + Ok(()) +} + +async fn load_auth_for_target( + config: &Config, + target: &LoginTarget, +) -> std::io::Result> { + CodexAuth::from_auth_storage( + &target.codex_home, + config.cli_auth_credentials_store_mode, + Some(&config.chatgpt_base_url), + ) + .await +} + +async fn record_profile_after_login(config: &Config, target: &LoginTarget) -> std::io::Result<()> { + if target.profile.is_none() { + return Ok(()); + } + match load_auth_for_target(config, target).await? { + Some(auth) => note_profile_login(config, target, &auth), + None => Err(std::io::Error::other(format!( + "login completed but no credentials were stored for {}", + target.label() + ))), + } +} + /// Installs a small file-backed tracing layer for direct `codex login` flows. /// /// This deliberately duplicates a narrow slice of the TUI logging setup instead of reusing it @@ -131,7 +213,10 @@ pub async fn login_with_chatgpt( server.block_until_done().await } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_with_chatgpt( + cli_config_overrides: CliConfigOverrides, + profile: Option, +) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting browser login flow"); @@ -142,15 +227,20 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let target = resolve_login_target_or_exit(&config, profile); match login_with_chatgpt( - config.codex_home.to_path_buf(), + target.codex_home.clone(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, ) .await { Ok(_) => { + if let Err(err) = record_profile_after_login(&config, &target).await { + eprintln!("Error updating auth profile metadata: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -163,6 +253,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> pub async fn run_login_with_api_key( cli_config_overrides: CliConfigOverrides, + profile: Option, api_key: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; @@ -174,12 +265,18 @@ pub async fn run_login_with_api_key( std::process::exit(1); } + let target = resolve_login_target_or_exit(&config, profile); + match login_with_api_key( - &config.codex_home, + &target.codex_home, &api_key, config.cli_auth_credentials_store_mode, ) { Ok(_) => { + if let Err(err) = record_profile_after_login(&config, &target).await { + eprintln!("Error updating auth profile metadata: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -192,6 +289,7 @@ pub async fn run_login_with_api_key( pub async fn run_login_with_access_token( cli_config_overrides: CliConfigOverrides, + profile: Option, access_token: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; @@ -203,8 +301,10 @@ pub async fn run_login_with_access_token( std::process::exit(1); } + let target = resolve_login_target_or_exit(&config, profile); + match login_with_access_token( - &config.codex_home, + &target.codex_home, &access_token, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), @@ -212,6 +312,10 @@ pub async fn run_login_with_access_token( .await { Ok(_) => { + if let Err(err) = record_profile_after_login(&config, &target).await { + eprintln!("Error updating auth profile metadata: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -266,6 +370,7 @@ fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_messag /// Login using the OAuth device code flow. pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, + profile: Option, issuer_base_url: Option, client_id: Option, ) -> ! { @@ -277,8 +382,9 @@ pub async fn run_login_with_device_code( std::process::exit(1); } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let target = resolve_login_target_or_exit(&config, profile); let mut opts = ServerOptions::new( - config.codex_home.to_path_buf(), + target.codex_home.clone(), client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, @@ -288,6 +394,10 @@ pub async fn run_login_with_device_code( } match run_device_code_login(opts).await { Ok(()) => { + if let Err(err) = record_profile_after_login(&config, &target).await { + eprintln!("Error updating auth profile metadata: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -298,84 +408,22 @@ pub async fn run_login_with_device_code( } } -/// Prefers device-code login (with `open_browser = false`) when headless environment is detected, but keeps -/// `codex login` working in environments where device-code may be disabled/feature-gated. -/// If `run_device_code_login` returns `ErrorKind::NotFound` ("device-code unsupported"), this -/// falls back to starting the local browser login server. -pub async fn run_login_with_device_code_fallback_to_browser( +pub async fn run_login_status( cli_config_overrides: CliConfigOverrides, - issuer_base_url: Option, - client_id: Option, + profile: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - let _login_log_guard = init_login_file_logging(&config); - tracing::info!("starting login flow with device code fallback"); - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); - std::process::exit(1); - } + let target = resolve_login_target_or_exit(&config, profile); - let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - let mut opts = ServerOptions::new( - config.codex_home.to_path_buf(), - client_id.unwrap_or(CLIENT_ID.to_string()), - forced_chatgpt_workspace_id, - config.cli_auth_credentials_store_mode, - ); - if let Some(iss) = issuer_base_url { - opts.issuer = iss; - } - opts.open_browser = false; - - match run_device_code_login(opts.clone()).await { - Ok(()) => { - eprintln!("{LOGIN_SUCCESS_MESSAGE}"); - std::process::exit(0); - } - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - eprintln!("Device code login is not enabled; falling back to browser login."); - match run_login_server(opts) { - Ok(server) => { - print_login_server_start(server.actual_port, &server.auth_url); - match server.block_until_done().await { - Ok(()) => { - eprintln!("{LOGIN_SUCCESS_MESSAGE}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error logging in: {e}"); - std::process::exit(1); - } - } - } - Err(e) => { - eprintln!("Error logging in: {e}"); - std::process::exit(1); - } - } - } else { - eprintln!("Error logging in with device code: {e}"); - std::process::exit(1); - } - } - } -} - -pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides).await; - - match CodexAuth::from_auth_storage( - &config.codex_home, - config.cli_auth_credentials_store_mode, - Some(&config.chatgpt_base_url), - ) - .await - { + match load_auth_for_target(&config, &target).await { Ok(Some(auth)) => match auth.auth_mode() { AuthMode::ApiKey => match auth.get_token() { Ok(api_key) => { - eprintln!("Logged in using an API key - {}", safe_format_key(&api_key)); + eprintln!( + "Logged in using an API key{} - {}", + profile_suffix(&target), + safe_format_key(&api_key) + ); std::process::exit(0); } Err(e) => { @@ -384,15 +432,18 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } }, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens => { - eprintln!("Logged in using ChatGPT"); + eprintln!("Logged in using ChatGPT{}", profile_suffix(&target)); std::process::exit(0); } AuthMode::AgentIdentity => { - eprintln!("Logged in using access token"); + eprintln!("Logged in using access token{}", profile_suffix(&target)); std::process::exit(0); } AuthMode::PersonalAccessToken => { - eprintln!("Logged in using personal access token"); + eprintln!( + "Logged in using personal access token{}", + profile_suffix(&target) + ); std::process::exit(0); } }, @@ -407,15 +458,51 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } } -pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_profiles(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let profiles = match list_auth_profiles(&config.codex_home) { + Ok(profiles) => profiles, + Err(err) => { + eprintln!("Error listing auth profiles: {err}"); + std::process::exit(1); + } + }; - match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { + if profiles.is_empty() { + eprintln!("No auth profiles configured"); + std::process::exit(0); + } + + for profile in profiles { + let mut details = Vec::new(); + if let Some(email) = profile.metadata.email.as_deref() { + details.push(email.to_string()); + } + if profile.metadata.priming_enabled == Some(true) { + details.push("priming enabled".to_string()); + } + let suffix = if details.is_empty() { + String::new() + } else { + format!(" ({})", details.join(", ")) + }; + eprintln!("{}{}", profile.name, suffix); + } + std::process::exit(0); +} + +pub async fn run_logout(cli_config_overrides: CliConfigOverrides, profile: Option) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + let target = resolve_login_target_or_exit(&config, profile); + + match logout_with_revoke(&target.codex_home, config.cli_auth_credentials_store_mode).await { Ok(true) => { + remove_profile_metadata_after_logout(&config, &target); eprintln!("Successfully logged out"); std::process::exit(0); } Ok(false) => { + remove_profile_metadata_after_logout(&config, &target); eprintln!("Not logged in"); std::process::exit(0); } @@ -426,6 +513,24 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { } } +fn remove_profile_metadata_after_logout(config: &Config, target: &LoginTarget) { + if let Some(profile) = target.profile.as_deref() + && let Err(err) = codex_login::remove_auth_profile_metadata(&config.codex_home, profile) + { + eprintln!("Warning: failed to update auth profile metadata: {err}"); + } +} + +fn resolve_login_target_or_exit(config: &Config, profile: Option) -> LoginTarget { + match resolve_login_target(config, profile) { + Ok(target) => target, + Err(err) => { + eprintln!("Invalid auth profile: {err}"); + std::process::exit(2); + } + } +} + async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { let cli_overrides = match cli_config_overrides.parse_overrides() { Ok(v) => v, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 203902b2623d..af1c5ed6794b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,6 +14,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; +use codex_cli::run_login_profiles; use codex_cli::run_login_status; use codex_cli::run_login_with_access_token; use codex_cli::run_login_with_api_key; @@ -415,6 +416,10 @@ struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + /// Store or inspect credentials for a named Codex Lab auth profile. + #[arg(long = "profile", value_name = "NAME", global = true)] + profile: Option, + #[arg( long = "with-api-key", help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" @@ -457,12 +462,19 @@ struct LoginCommand { enum LoginSubcommand { /// Show login status. Status, + + /// List named auth profiles. + Profiles, } #[derive(Debug, Parser)] struct LogoutCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + + /// Remove credentials for a named Codex Lab auth profile. + #[arg(long = "profile", value_name = "NAME")] + profile: Option, } #[derive(Debug, Parser)] @@ -1254,7 +1266,10 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths, command_name: &'static str) -> ); match login_cli.action { Some(LoginSubcommand::Status) => { - run_login_status(login_cli.config_overrides).await; + run_login_status(login_cli.config_overrides, login_cli.profile).await; + } + Some(LoginSubcommand::Profiles) => { + run_login_profiles(login_cli.config_overrides).await; } None => { if login_cli.with_api_key && login_cli.with_access_token { @@ -1265,6 +1280,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths, command_name: &'static str) -> } else if login_cli.use_device_code { run_login_with_device_code( login_cli.config_overrides, + login_cli.profile, login_cli.issuer_base_url, login_cli.client_id, ) @@ -1276,12 +1292,22 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths, command_name: &'static str) -> std::process::exit(1); } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); - run_login_with_api_key(login_cli.config_overrides, api_key).await; + run_login_with_api_key( + login_cli.config_overrides, + login_cli.profile, + api_key, + ) + .await; } else if login_cli.with_access_token { let access_token = read_access_token_from_stdin(); - run_login_with_access_token(login_cli.config_overrides, access_token).await; + run_login_with_access_token( + login_cli.config_overrides, + login_cli.profile, + access_token, + ) + .await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login_with_chatgpt(login_cli.config_overrides, login_cli.profile).await; } } } @@ -1296,7 +1322,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths, command_name: &'static str) -> &mut logout_cli.config_overrides, root_config_overrides.clone(), ); - run_logout(logout_cli.config_overrides).await; + run_logout(logout_cli.config_overrides, logout_cli.profile).await; } Some(Subcommand::Completion(completion_cli)) => { reject_remote_mode_for_subcommand( @@ -2583,6 +2609,53 @@ mod tests { ); } + #[test] + fn login_accepts_auth_profile_flag() { + let cli = + MultitoolCli::try_parse_from(["codex", "login", "--profile", "work", "--with-api-key"]) + .expect("parse should succeed"); + + let Some(Subcommand::Login(login)) = cli.subcommand else { + panic!("expected login subcommand"); + }; + assert_eq!(login.profile.as_deref(), Some("work")); + assert!(login.with_api_key); + } + + #[test] + fn login_status_accepts_auth_profile_flag() { + let cli = MultitoolCli::try_parse_from(["codex", "login", "status", "--profile", "backup"]) + .expect("parse should succeed"); + + let Some(Subcommand::Login(login)) = cli.subcommand else { + panic!("expected login subcommand"); + }; + assert_eq!(login.profile.as_deref(), Some("backup")); + assert!(matches!(login.action, Some(LoginSubcommand::Status))); + } + + #[test] + fn login_profiles_subcommand_parses() { + let cli = MultitoolCli::try_parse_from(["codex", "login", "profiles"]) + .expect("parse should succeed"); + + let Some(Subcommand::Login(login)) = cli.subcommand else { + panic!("expected login subcommand"); + }; + assert!(matches!(login.action, Some(LoginSubcommand::Profiles))); + } + + #[test] + fn logout_accepts_auth_profile_flag() { + let cli = MultitoolCli::try_parse_from(["codex", "logout", "--profile", "work"]) + .expect("parse should succeed"); + + let Some(Subcommand::Logout(logout)) = cli.subcommand else { + panic!("expected logout subcommand"); + }; + assert_eq!(logout.profile.as_deref(), Some("work")); + } + #[test] fn exec_resume_last_accepts_prompt_positional() { let cli = diff --git a/codex-rs/login/src/auth_profiles.rs b/codex-rs/login/src/auth_profiles.rs new file mode 100644 index 000000000000..4403a99d7635 --- /dev/null +++ b/codex-rs/login/src/auth_profiles.rs @@ -0,0 +1,309 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fs; +use std::fs::OpenOptions; +use std::io; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +const PROFILE_METADATA_FILE: &str = "auth-profiles.json"; +const PROFILE_DIR: &str = "auth-profiles"; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct AuthProfilesFile { + pub version: u32, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_profile: Option, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub profiles: BTreeMap, +} + +impl AuthProfilesFile { + fn new() -> Self { + Self { + version: 1, + ..Self::default() + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct AuthProfileMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_at: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_login_at: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priming_enabled: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthProfileEntry { + pub name: String, + pub home: PathBuf, + pub metadata: AuthProfileMetadata, +} + +pub fn profile_metadata_path(codex_home: &Path) -> PathBuf { + codex_home.join(PROFILE_METADATA_FILE) +} + +pub fn profile_home(codex_home: &Path, profile_name: &str) -> io::Result { + let safe_name = validate_profile_name(profile_name)?; + Ok(codex_home.join(PROFILE_DIR).join(safe_name)) +} + +pub fn validate_profile_name(profile_name: &str) -> io::Result<&str> { + let trimmed = profile_name.trim(); + if trimmed.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "auth profile name cannot be empty", + )); + } + if trimmed != profile_name { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "auth profile name cannot start or end with whitespace", + )); + } + if trimmed == "." || trimmed == ".." { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "auth profile name cannot be . or ..", + )); + } + if !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "auth profile names may contain only ASCII letters, numbers, '-' and '_'", + )); + } + Ok(trimmed) +} + +pub fn load_auth_profiles(codex_home: &Path) -> io::Result { + let path = profile_metadata_path(codex_home); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(AuthProfilesFile::new()), + Err(err) => return Err(err), + }; + let mut profiles: AuthProfilesFile = serde_json::from_str(&raw).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse {}: {err}", path.display()), + ) + })?; + if profiles.version == 0 { + profiles.version = 1; + } + Ok(profiles) +} + +pub fn save_auth_profiles(codex_home: &Path, profiles: &AuthProfilesFile) -> io::Result<()> { + fs::create_dir_all(codex_home)?; + let path = profile_metadata_path(codex_home); + let tmp_path = path.with_extension("json.tmp"); + let raw = serde_json::to_string_pretty(profiles).map_err(io::Error::other)?; + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(&tmp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + file.write_all(raw.as_bytes())?; + fs::rename(tmp_path, path)?; + Ok(()) +} + +pub fn upsert_auth_profile( + codex_home: &Path, + profile_name: &str, + update: impl FnOnce(&mut AuthProfileMetadata), +) -> io::Result { + let profile_name = validate_profile_name(profile_name)?.to_string(); + let mut profiles = load_auth_profiles(codex_home)?; + profiles.version = 1; + let metadata = profiles.profiles.entry(profile_name.clone()).or_default(); + update(metadata); + if profiles.active_profile.is_none() { + profiles.active_profile = Some(profile_name.clone()); + } + save_auth_profiles(codex_home, &profiles)?; + Ok(AuthProfileEntry { + home: profile_home(codex_home, &profile_name)?, + metadata: profiles + .profiles + .get(&profile_name) + .cloned() + .unwrap_or_default(), + name: profile_name, + }) +} + +pub fn record_auth_profile_login( + codex_home: &Path, + profile_name: &str, + account_id: Option, + email: Option, +) -> io::Result { + let now = Utc::now(); + upsert_auth_profile(codex_home, profile_name, |metadata| { + metadata.last_login_at = Some(now); + metadata.last_used_at = Some(now); + metadata.account_id = account_id; + metadata.email = email; + }) +} + +pub fn list_auth_profiles(codex_home: &Path) -> io::Result> { + let profiles = load_auth_profiles(codex_home)?; + profiles + .profiles + .into_iter() + .map(|(name, metadata)| { + Ok(AuthProfileEntry { + home: profile_home(codex_home, &name)?, + metadata, + name, + }) + }) + .collect() +} + +pub fn remove_auth_profile_metadata(codex_home: &Path, profile_name: &str) -> io::Result { + let profile_name = validate_profile_name(profile_name)?.to_string(); + let mut profiles = load_auth_profiles(codex_home)?; + let removed = profiles.profiles.remove(&profile_name).is_some(); + if !removed { + return Ok(false); + } + if profiles.active_profile.as_deref() == Some(&profile_name) { + profiles.active_profile = profiles.profiles.keys().next().cloned(); + } + save_auth_profiles(codex_home, &profiles)?; + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn profile_home_rejects_path_like_names() { + let temp = TempDir::new().expect("tempdir"); + + assert!(profile_home(temp.path(), "../secret").is_err()); + assert!(profile_home(temp.path(), "work/account").is_err()); + assert!(profile_home(temp.path(), " work").is_err()); + } + + #[test] + fn upsert_and_list_profiles_round_trip_metadata() { + let temp = TempDir::new().expect("tempdir"); + + let entry = upsert_auth_profile(temp.path(), "work", |metadata| { + metadata.email = Some("me@example.com".to_string()); + metadata.priming_enabled = Some(true); + }) + .expect("upsert profile"); + + assert_eq!(entry.name, "work"); + assert_eq!(entry.home, temp.path().join("auth-profiles").join("work")); + + let profiles = list_auth_profiles(temp.path()).expect("list profiles"); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].name, "work"); + assert_eq!( + profiles[0].metadata.email.as_deref(), + Some("me@example.com") + ); + assert_eq!(profiles[0].metadata.priming_enabled, Some(true)); + } + + #[cfg(unix)] + #[test] + fn saved_profiles_metadata_is_private() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new().expect("tempdir"); + + upsert_auth_profile(temp.path(), "work", |_| {}).expect("upsert profile"); + + let mode = fs::metadata(profile_metadata_path(temp.path())) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn saved_profiles_metadata_restricts_stale_tmp_file() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new().expect("tempdir"); + let tmp_path = profile_metadata_path(temp.path()).with_extension("json.tmp"); + fs::write(&tmp_path, "{}").expect("write stale tmp"); + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o644)) + .expect("make stale tmp permissive"); + + upsert_auth_profile(temp.path(), "work", |_| {}).expect("upsert profile"); + + let mode = fs::metadata(profile_metadata_path(temp.path())) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn remove_profile_metadata_updates_active_profile() { + let temp = TempDir::new().expect("tempdir"); + + upsert_auth_profile(temp.path(), "work", |_| {}).expect("upsert work profile"); + upsert_auth_profile(temp.path(), "backup", |_| {}).expect("upsert backup profile"); + + assert!( + remove_auth_profile_metadata(temp.path(), "work").expect("remove profile metadata") + ); + + let profiles = load_auth_profiles(temp.path()).expect("load profiles"); + assert_eq!(profiles.active_profile.as_deref(), Some("backup")); + assert!(!profiles.profiles.contains_key("work")); + assert!(profiles.profiles.contains_key("backup")); + } +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 990cf8b80e18..5825c910c8f7 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod auth_env_telemetry; +pub mod auth_profiles; pub mod token_data; mod device_code_auth; @@ -47,4 +48,13 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use auth_profiles::AuthProfileEntry; +pub use auth_profiles::AuthProfileMetadata; +pub use auth_profiles::AuthProfilesFile; +pub use auth_profiles::list_auth_profiles; +pub use auth_profiles::profile_home; +pub use auth_profiles::record_auth_profile_login; +pub use auth_profiles::remove_auth_profile_metadata; +pub use auth_profiles::upsert_auth_profile; +pub use auth_profiles::validate_profile_name; pub use token_data::TokenData;