mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user