Instrument device auth requests with client identity

Problem: Device authorization requests omitted Codex client identity metadata, limiting detection and investigation of third-party device-flow reuse.

Solution: Send the existing originator, User-Agent, and trusted-path installation ID on both device-auth endpoints while withholding the durable ID from custom issuers. Manually verified production user-code issuance and repeated token polling with an isolated CODEX_HOME, then cancelled before authorization with no tokens persisted.
This commit is contained in:
Eric Traut
2026-07-08 14:15:39 -07:00
parent 0bbea86a6a
commit 76b0575c01
10 changed files with 171 additions and 17 deletions

View File

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

View File

@@ -71,6 +71,7 @@ pub(crate) struct AccountRequestProcessor {
config: Arc<Config>,
config_manager: ConfigManager,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
installation_id: String,
}
impl AccountRequestProcessor {
@@ -80,6 +81,7 @@ impl AccountRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
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)

View File

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

View File

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

View File

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

View File

@@ -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<UserCodeResp> {
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<CodeSuccessResp> {
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<Device
let base_url = opts.issuer.trim_end_matches('/');
// The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint
// paths are not resolved separately.
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 uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
let installation_id = opts
.device_auth_metadata
.as_ref()
.map(|metadata| metadata.installation_id.as_str());
let uc = request_user_code(&client, &api_base_url, &opts.client_id, installation_id).await?;
Ok(DeviceCode {
verification_url: format!("{base_url}/codex/device"),
@@ -177,8 +186,12 @@ pub async fn complete_device_code_login(
device_code: DeviceCode,
) -> 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?;

View File

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

View File

@@ -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<AuthRouteConfig>,
pub device_auth_metadata: Option<DeviceAuthMetadata>,
}
/// 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,
}
}
}

View File

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

View File

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