diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ca122920bd..64e3c0ee3c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -287,7 +287,7 @@ impl MessageProcessor { Some(analytics_events_client.clone()), Arc::clone(&thread_store), codex_core::local_agent_graph_store_from_state_db(state_db.as_ref()), - installation_id, + installation_id.clone(), Some(app_server_attestation_provider( outgoing.clone(), thread_state_manager.clone(), @@ -318,6 +318,7 @@ impl MessageProcessor { outgoing.clone(), Arc::clone(&config), config_manager.clone(), + installation_id.clone(), ); let apps_processor = AppsRequestProcessor::new( auth_manager.clone(), diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index f4acc5a8e4..3f5413aca9 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -71,6 +71,7 @@ pub(crate) struct AccountRequestProcessor { config: Arc, config_manager: ConfigManager, active_login: Arc>>, + installation_id: String, } impl AccountRequestProcessor { @@ -80,6 +81,7 @@ impl AccountRequestProcessor { outgoing: Arc, config: Arc, config_manager: ConfigManager, + installation_id: String, ) -> Self { Self { auth_manager, @@ -88,6 +90,7 @@ impl AccountRequestProcessor { config, config_manager, active_login: Arc::new(Mutex::new(None)), + installation_id, } } @@ -378,6 +381,9 @@ impl AccountRequestProcessor { open_browser: false, codex_streamlined_login, login_success_page, + device_auth_metadata: Some(codex_login::DeviceAuthMetadata { + installation_id: self.installation_id.clone(), + }), ..LoginServerOptions::new( config.codex_home.to_path_buf(), oauth_client_id(), @@ -394,6 +400,7 @@ impl AccountRequestProcessor { && !issuer.trim().is_empty() { opts.issuer = issuer; + opts.device_auth_metadata = None; } if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page && let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index ccf46321e6..3220f126c7 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; +use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; @@ -1172,7 +1173,17 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> ]) .build() .await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let client_name = "device-auth-app-server-test"; + let client_version = "1.2.3"; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: client_name.to_string(), + title: None, + version: client_version.to_string(), + }), + ) + .await??; let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; let resp: JSONRPCResponse = timeout( @@ -1220,6 +1231,29 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> codex_home.path().join("auth.json").exists(), "auth.json should be created when device code login succeeds" ); + let requests = mock_server.received_requests().await.unwrap(); + let device_requests: Vec<_> = requests + .iter() + .filter(|request| request.url.path().starts_with("/api/accounts/deviceauth/")) + .collect(); + assert_eq!(device_requests.len(), 2); + for request in device_requests { + assert_eq!( + request + .headers + .get("originator") + .and_then(|value| value.to_str().ok()), + Some(client_name) + ); + assert!( + request + .headers + .get("user-agent") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains(&format!("({client_name}; {client_version})"))) + ); + assert!(request.headers.get("x-codex-installation-id").is_none()); + } Ok(()) } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 98d56bec12..e0952b6e43 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,6 +13,7 @@ use codex_login::AuthKeyringBackendKind; use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::CodexAuth; +use codex_login::DeviceAuthMetadata; use codex_login::ServerOptions; use codex_login::login_with_access_token; use codex_login::login_with_api_key; @@ -316,6 +317,14 @@ pub async fn run_login_with_device_code( std::process::exit(1); } let auth_route_config = config.auth_route_config(); + let installation_id = match codex_core::resolve_installation_id(&config.codex_home).await { + Ok(installation_id) => installation_id, + Err(err) => { + eprintln!("Error resolving installation ID: {err}"); + std::process::exit(1); + } + }; + let uses_default_issuer = issuer_base_url.is_none(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, @@ -335,6 +344,9 @@ pub async fn run_login_with_device_code( if let Some(iss) = issuer_base_url { opts.issuer = iss; } + if uses_default_issuer { + opts.device_auth_metadata = Some(DeviceAuthMetadata { installation_id }); + } match run_device_code_login(opts).await { Ok(()) => { eprintln!("{LOGIN_SUCCESS_MESSAGE}"); @@ -364,6 +376,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( std::process::exit(1); } let auth_route_config = config.auth_route_config(); + let installation_id = match codex_core::resolve_installation_id(&config.codex_home).await { + Ok(installation_id) => installation_id, + Err(err) => { + eprintln!("Error resolving installation ID: {err}"); + std::process::exit(1); + } + }; + let uses_default_issuer = issuer_base_url.is_none(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, @@ -384,6 +404,9 @@ pub async fn run_login_with_device_code_fallback_to_browser( if let Some(iss) = issuer_base_url { opts.issuer = iss; } + if uses_default_issuer { + opts.device_auth_metadata = Some(DeviceAuthMetadata { installation_id }); + } opts.open_browser = false; match run_device_code_login(opts.clone()).await { diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 4f37f41418..4cf5aca68a 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -169,6 +169,26 @@ async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Re "client_id": CLIENT_ID, }) ); + let expected_originator = + std::env::var(codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) + .unwrap_or_else(|_| codex_login::default_client::DEFAULT_ORIGINATOR.to_string()); + for request in &requests[1..=2] { + assert_eq!( + request + .headers + .get("originator") + .and_then(|value| value.to_str().ok()), + Some(expected_originator.as_str()) + ); + assert!( + request + .headers + .get("user-agent") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(&format!("{expected_originator}/"))) + ); + assert!(request.headers.get("x-codex-installation-id").is_none()); + } let auth = read_auth_json(codex_home.path())?; assert_eq!(auth["tokens"]["refresh_token"], "new-refresh"); diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 70e6dff225..21dc3f6ad9 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,7 +6,7 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; -use crate::default_client::build_raw_auth_reqwest_client; +use crate::default_client::build_default_auth_reqwest_client; use crate::pkce::PkceCodes; use crate::server::ServerOptions; use std::io; @@ -14,6 +14,7 @@ use std::io; const ANSI_BLUE: &str = "\x1b[94m"; const ANSI_GRAY: &str = "\x1b[90m"; const ANSI_RESET: &str = "\x1b[0m"; +const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; #[derive(Debug, Clone)] pub struct DeviceCode { @@ -63,19 +64,21 @@ async fn request_user_code( client: &reqwest::Client, auth_base_url: &str, client_id: &str, + installation_id: Option<&str>, ) -> std::io::Result { let url = format!("{auth_base_url}/deviceauth/usercode"); let body = serde_json::to_string(&UserCodeReq { client_id: client_id.to_string(), }) .map_err(std::io::Error::other)?; - let resp = client + let mut request = client .post(url) .header("Content-Type", "application/json") - .body(body) - .send() - .await - .map_err(std::io::Error::other)?; + .body(body); + if let Some(installation_id) = installation_id { + request = request.header(X_CODEX_INSTALLATION_ID_HEADER, installation_id); + } + let resp = request.send().await.map_err(std::io::Error::other)?; if !resp.status().is_success() { let status = resp.status(); @@ -102,6 +105,7 @@ async fn poll_for_token( device_auth_id: &str, user_code: &str, interval: u64, + installation_id: Option<&str>, ) -> std::io::Result { let url = format!("{auth_base_url}/deviceauth/token"); let max_wait = Duration::from_secs(15 * 60); @@ -113,13 +117,14 @@ async fn poll_for_token( user_code: user_code.to_string(), }) .map_err(std::io::Error::other)?; - let resp = client + let mut request = client .post(&url) .header("Content-Type", "application/json") - .body(body) - .send() - .await - .map_err(std::io::Error::other)?; + .body(body); + if let Some(installation_id) = installation_id { + request = request.header(X_CODEX_INSTALLATION_ID_HEADER, installation_id); + } + let resp = request.send().await.map_err(std::io::Error::other)?; let status = resp.status(); @@ -160,9 +165,13 @@ pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result std::io::Result<()> { let base_url = opts.issuer.trim_end_matches('/'); - let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; + let client = build_default_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); + let installation_id = opts + .device_auth_metadata + .as_ref() + .map(|metadata| metadata.installation_id.as_str()); let code_resp = poll_for_token( &client, @@ -186,6 +199,7 @@ pub async fn complete_device_code_login( &device_code.device_auth_id, &device_code.user_code, device_code.interval, + installation_id, ) .await?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 9b3f82f382..5b5ce1d110 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -14,6 +14,7 @@ pub use device_code_auth::DeviceCode; pub use device_code_auth::complete_device_code_login; pub use device_code_auth::request_device_code; pub use device_code_auth::run_device_code_login; +pub use server::DeviceAuthMetadata; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 01671bf0b1..e99e6472d7 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -78,6 +78,13 @@ pub struct ServerOptions { pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, pub auth_route_config: Option, + pub device_auth_metadata: Option, +} + +/// Metadata attached only to device authorization requests sent to a trusted issuer. +#[derive(Debug, Clone)] +pub struct DeviceAuthMetadata { + pub installation_id: String, } impl ServerOptions { @@ -103,6 +110,7 @@ impl ServerOptions { cli_auth_credentials_store_mode, auth_keyring_backend_kind, auth_route_config, + device_auth_metadata: None, } } } diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 83cae8af5f..cf870405b7 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -5,6 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; +use codex_login::DeviceAuthMetadata; use codex_login::ServerOptions; use codex_login::auth::load_auth_dot_json; use codex_login::run_device_code_login; @@ -144,7 +145,11 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { mock_oauth_token_single(&mock_server, jwt.clone()).await; let issuer = mock_server.uri(); - let opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); + let installation_id = "123e4567-e89b-42d3-a456-426614174099"; + let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); + opts.device_auth_metadata = Some(DeviceAuthMetadata { + installation_id: installation_id.to_string(), + }); run_device_code_login(opts) .await @@ -163,6 +168,38 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { assert_eq!(tokens.refresh_token, "refresh-token-123"); assert_eq!(tokens.id_token.raw_jwt, jwt); assert_eq!(tokens.account_id.as_deref(), Some(WORKSPACE_ID_ALLOWED)); + + let requests = mock_server.received_requests().await.unwrap(); + let device_requests: Vec<&Request> = requests + .iter() + .filter(|request| request.url.path().starts_with("/api/accounts/deviceauth/")) + .collect(); + assert_eq!(device_requests.len(), 3); + let expected_originator = codex_login::default_client::originator().value; + let expected_user_agent = codex_login::default_client::get_codex_user_agent(); + for request in device_requests { + assert_eq!( + request + .headers + .get("originator") + .and_then(|value| value.to_str().ok()), + Some(expected_originator.as_str()) + ); + assert_eq!( + request + .headers + .get("user-agent") + .and_then(|value| value.to_str().ok()), + Some(expected_user_agent.as_str()) + ); + assert_eq!( + request + .headers + .get("x-codex-installation-id") + .and_then(|value| value.to_str().ok()), + Some(installation_id) + ); + } Ok(()) } diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index dbcd396ef1..36fcece285 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -125,6 +125,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -190,6 +191,7 @@ async fn hosted_login_redirects_to_configured_open_app_url() -> Result<()> { codex_home: tmp.path().to_path_buf(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -243,6 +245,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -286,6 +289,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -330,6 +334,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -393,6 +398,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -464,6 +470,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -614,6 +621,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -638,6 +646,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, + device_auth_metadata: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port,