Add static mcp callback uri support (#8971)

Currently the callback URI for MCP authentication is dynamically
generated. More specifically, the callback URI is dynamic because the
port part of it is randomly chosen by the OS. This is not ideal as
callback URIs are recommended to be static and many authorization
servers do not support dynamic callback URIs.

This PR fixes that issue by exposing a new config option named
`mcp_oauth_callback_port`. When it is set, the callback URI is
constructed using this port rather than a random one chosen by the OS,
thereby making callback URI static.

Related issue: https://github.com/openai/codex/issues/8827
This commit is contained in:
WhammyLeaf
2026-01-12 17:57:04 +01:00
committed by GitHub
Unverified
parent 9659583559
commit d5562983d9
4 changed files with 73 additions and 1 deletions
@@ -2358,6 +2358,7 @@ impl CodexMessageProcessor {
env_http_headers,
scopes.as_deref().unwrap_or_default(),
timeout_secs,
config.mcp_oauth_callback_port,
)
.await
{
+2
View File
@@ -274,6 +274,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
http_headers.clone(),
env_http_headers.clone(),
&Vec::new(),
config.mcp_oauth_callback_port,
)
.await?;
println!("Successfully logged in.");
@@ -352,6 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
http_headers,
env_http_headers,
&scopes,
config.mcp_oauth_callback_port,
)
.await?;
println!("Successfully logged in to MCP server '{name}'.");
+42
View File
@@ -268,6 +268,11 @@ pub struct Config {
/// auto (default): keyring if available, otherwise file.
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
/// Optional fixed port to use for the local HTTP callback server used during MCP OAuth login.
///
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
pub mcp_oauth_callback_port: Option<u16>,
/// Combined provider map (defaults merged with user-defined overrides).
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -751,6 +756,10 @@ pub struct ConfigToml {
#[serde(default)]
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
/// When unset, Codex will bind to an ephemeral port chosen by the OS.
pub mcp_oauth_callback_port: Option<u16>,
/// User-defined provider entries that extend/override the built-in list.
#[serde(default)]
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -1361,6 +1370,7 @@ impl Config {
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
project_doc_fallback_filenames: cfg
@@ -3245,6 +3255,7 @@ model_verbosity = "high"
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_callback_port: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
@@ -3331,6 +3342,7 @@ model_verbosity = "high"
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_callback_port: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
@@ -3432,6 +3444,7 @@ model_verbosity = "high"
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_callback_port: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
@@ -3519,6 +3532,7 @@ model_verbosity = "high"
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: HashMap::new(),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_callback_port: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
@@ -3832,6 +3846,34 @@ trust_level = "untrusted"
assert_eq!(result, Some("explicit-provider".to_string()));
}
#[test]
fn config_toml_deserializes_mcp_oauth_callback_port() {
let toml = r#"mcp_oauth_callback_port = 4321"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
assert_eq!(cfg.mcp_oauth_callback_port, Some(4321));
}
#[test]
fn config_loads_mcp_oauth_callback_port_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.1"
mcp_oauth_callback_port = 5678
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for callback port");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(config.mcp_oauth_callback_port, Some(5678));
Ok(())
}
#[test]
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -6,6 +6,7 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use reqwest::ClientBuilder;
use rmcp::transport::auth::OAuthState;
use tiny_http::Response;
@@ -44,6 +45,7 @@ pub async fn perform_oauth_login(
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
callback_port: Option<u16>,
) -> Result<()> {
let headers = OauthHeaders {
http_headers,
@@ -56,6 +58,7 @@ pub async fn perform_oauth_login(
headers,
scopes,
true,
callback_port,
None,
)
.await?
@@ -63,6 +66,7 @@ pub async fn perform_oauth_login(
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn perform_oauth_login_return_url(
server_name: &str,
server_url: &str,
@@ -71,6 +75,7 @@ pub async fn perform_oauth_login_return_url(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
timeout_secs: Option<i64>,
callback_port: Option<u16>,
) -> Result<OauthLoginHandle> {
let headers = OauthHeaders {
http_headers,
@@ -83,6 +88,7 @@ pub async fn perform_oauth_login_return_url(
headers,
scopes,
false,
callback_port,
timeout_secs,
)
.await?;
@@ -188,7 +194,21 @@ struct OauthLoginFlow {
timeout: Duration,
}
fn resolve_callback_port(callback_port: Option<u16>) -> Result<Option<u16>> {
if let Some(config_port) = callback_port {
if config_port == 0 {
bail!(
"invalid MCP OAuth callback port `{config_port}`: port must be between 1 and 65535"
);
}
return Ok(Some(config_port));
}
Ok(None)
}
impl OauthLoginFlow {
#[allow(clippy::too_many_arguments)]
async fn new(
server_name: &str,
server_url: &str,
@@ -196,11 +216,18 @@ impl OauthLoginFlow {
headers: OauthHeaders,
scopes: &[String],
launch_browser: bool,
callback_port: Option<u16>,
timeout_secs: Option<i64>,
) -> Result<Self> {
const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300;
let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?);
let callback_port = resolve_callback_port(callback_port)?;
let bind_addr = match callback_port {
Some(port) => format!("127.0.0.1:{port}"),
None => "127.0.0.1:0".to_string(),
};
let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
let guard = CallbackServerGuard {
server: Arc::clone(&server),
};