Harden MCP OAuth callback handling (#40691)

## Why

MCP servers can share an OAuth callback URL. Without a validated issuer or a
server-specific callback path, an authorization response could be associated
with the wrong server.

## What changed

- Use stable callbacks when authorization metadata advertises issuer-bound
  responses, and validate the returned issuer before exchanging the code.
- Retain server-specific callback IDs for providers without issuer support,
  including fallback to the global or default callback for legacy registered
  clients.
- Persist registered callback URLs for MCP servers and plugins, and insert the
  active listener port into portless loopback redirects.

## Testing

Add coverage for issuer validation, callback-mode discovery, registered and
legacy clients, plugin OAuth, CLI persistence, and loopback listener ports.

GitOrigin-RevId: 2878c92e237fc17fd3def0bd2e1cce3e104a3db8
This commit is contained in:
stevenlee-oai
2026-08-25 20:26:23 +00:00
committed by copyberry
parent 32ce703a80
commit 9be8d6e1c3
28 changed files with 1096 additions and 127 deletions
@@ -3,6 +3,7 @@ use super::*;
use codex_core::McpManager;
use codex_mcp::McpServerSource;
use codex_mcp::ReadResourceRequestParams;
use codex_mcp::resolve_oauth_callback;
use crate::thread_state::ThreadStateManager;
@@ -204,6 +205,11 @@ impl McpRequestProcessor {
let resolved_scopes =
resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes);
let oauth_credential_name = server.oauth_credential_name(&name);
let callback_url =
resolve_oauth_callback(server, &url, mcp_config.mcp_oauth_callback_url.as_deref())
.map_err(|err| {
internal_error(format!("failed to resolve MCP OAuth callback: {err}"))
})?;
let handle = perform_oauth_login_return_url(
oauth_credential_name.as_ref(),
@@ -218,6 +224,7 @@ impl McpRequestProcessor {
server.oauth_resource.as_deref(),
timeout_secs,
server.oauth_callback_port(mcp_config.mcp_oauth_callback_port),
callback_url.as_deref(),
mcp_config.mcp_oauth_callback_url.as_deref(),
http_client,
redirect_mode,
@@ -28,6 +28,7 @@ use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::McpRuntimeContext;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_callback;
use codex_mcp::should_retry_without_scopes;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
@@ -1943,12 +1944,25 @@ impl PluginRequestProcessor {
let store_mode = config.mcp_oauth_credentials_store_mode;
let keyring_backend_kind = config.auth_keyring_backend_kind();
let callback_port = server.oauth_callback_port(config.mcp_oauth_callback_port);
let callback_url = config.mcp_oauth_callback_url.clone();
let callback_url = match resolve_oauth_callback(
&server,
&oauth_config.url,
config.mcp_oauth_callback_url.as_deref(),
) {
Ok(callback_url) => callback_url,
Err(error) => {
warn!(
"failed to resolve MCP OAuth callback for plugin install {name}: {error}"
);
continue;
}
};
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
let oauth_credential_name = server.oauth_credential_name(&name).into_owned();
let thread_manager = Arc::clone(&self.thread_manager);
let http_client = Arc::clone(&http_client);
let global_callback_url = config.mcp_oauth_callback_url.clone();
tokio::spawn(async move {
let oauth_client_id = server.oauth_client_id();
@@ -1965,6 +1979,7 @@ impl PluginRequestProcessor {
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
global_callback_url.as_deref(),
Arc::clone(&http_client),
redirect_mode,
)
@@ -1985,6 +2000,7 @@ impl PluginRequestProcessor {
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
global_callback_url.as_deref(),
http_client,
redirect_mode,
)
@@ -31,6 +31,8 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_core::config::set_project_trust_level;
use codex_http_client::HttpClientBuilder;
use codex_protocol::config_types::TrustLevel;
use codex_rmcp_client::McpOAuthCallbackMode;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use core_test_support::skip_if_remote;
use core_test_support::stdio_server_bin;
use pretty_assertions::assert_eq;
@@ -67,16 +69,73 @@ use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[test_case(false, None, None, None, true; "legacy callback")]
#[test_case(
false,
Some("http://127.0.0.1/callback/registered"),
None,
None,
true;
"ordinary configured callback falls back to default"
)]
#[test_case(
false,
Some("http://127.0.0.1/callback/registered"),
Some("http://127.0.0.1/global/callback"),
None,
true;
"ordinary configured callback falls back to global"
)]
#[test_case(
false,
Some("http://127.0.0.1/callback/registered"),
Some("http://127.0.0.1/global/callback"),
Some("https://unexpected.example"),
false;
"legacy callback fallback rejects mismatched issuer"
)]
#[test_case(
true,
Some("http://127.0.0.1/callback"),
None,
Some("matching"),
true;
"matching issuer"
)]
#[test_case(
true,
Some("http://127.0.0.1/callback"),
None,
Some("https://unexpected.example"),
false;
"mismatched issuer"
)]
#[test_case(
true,
Some("http://127.0.0.1/callback"),
None,
None,
false;
"missing issuer"
)]
#[tokio::test]
async fn oauth_login_uses_http_headers_helper() -> Result<()> {
async fn oauth_login_validates_callback_issuer_and_uses_http_headers_helper(
issuer_supported: bool,
configured_callback: Option<&str>,
global_callback: Option<&str>,
callback_issuer: Option<&str>,
expected_success: bool,
) -> Result<()> {
let oauth = MockServer::start().await;
let issuer = format!("{}/mcp", oauth.uri());
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.and(header("x-gateway", "gateway-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": format!("{}/mcp", oauth.uri()),
"issuer": issuer,
"authorization_endpoint": format!("{}/oauth/authorize", oauth.uri()),
"token_endpoint": format!("{}/oauth/token", oauth.uri()),
"authorization_response_iss_parameter_supported": issuer_supported,
})))
.mount(&oauth)
.await;
@@ -87,7 +146,7 @@ async fn oauth_login_uses_http_headers_helper() -> Result<()> {
"access_token": "oauth-token",
"token_type": "Bearer",
})))
.expect(1)
.expect(u64::from(expected_success))
.mount(&oauth)
.await;
@@ -97,15 +156,23 @@ async fn oauth_login_uses_http_headers_helper() -> Result<()> {
} else {
r#"printf '{"X-Gateway":"gateway-token"}'"#
};
let saved_callback = configured_callback
.map(|callback| format!("callback_url = \"{callback}\"\n"))
.unwrap_or_default();
let global_callback_config = global_callback
.map(|callback| format!("mcp_oauth_callback_url = \"{callback}\"\n"))
.unwrap_or_default();
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"mcp_oauth_credentials_store = \"file\"\n\
{global_callback_config}\
[mcp_servers.gateway]\n\
url = \"{}/mcp\"\n\
http_headers_helper = {}\n\
[mcp_servers.gateway.oauth]\n\
client_id = \"test-client\"\n",
client_id = \"test-client\"\n\
{saved_callback}",
oauth.uri(),
toml::Value::String(helper_command.to_string()),
),
@@ -126,10 +193,32 @@ async fn oauth_login_uses_http_headers_helper() -> Result<()> {
let authorization_url = Url::parse(&response.authorization_url)?;
let query: BTreeMap<_, _> = authorization_url.query_pairs().into_owned().collect();
let mut callback_url = Url::parse(&query["redirect_uri"])?;
let expected_callback = if issuer_supported {
configured_callback
.expect("issuer-bound cases configure a stable callback")
.to_string()
} else {
resolve_mcp_oauth_callback_url(
&issuer,
global_callback,
McpOAuthCallbackMode::CallbackSpecific,
)?
};
assert_eq!(callback_url.path(), Url::parse(&expected_callback)?.path());
callback_url
.query_pairs_mut()
.append_pair("code", "test-code")
.append_pair("state", &query["state"]);
if let Some(callback_issuer) = callback_issuer {
let callback_issuer = if callback_issuer == "matching" {
issuer.as_str()
} else {
callback_issuer
};
callback_url
.query_pairs_mut()
.append_pair("iss", callback_issuer);
}
HttpClientBuilder::new()
.build_direct()?
.get(callback_url)
@@ -142,13 +231,13 @@ async fn oauth_login_uses_http_headers_helper() -> Result<()> {
)
.await??;
assert_eq!(
completed,
McpServerOauthLoginCompletedNotification {
name: "gateway".to_string(),
thread_id: None,
success: true,
error: None,
}
(
completed.name.as_str(),
completed.thread_id,
completed.success,
completed.error.is_some(),
),
("gateway", None, expected_success, !expected_success)
);
oauth.verify().await;
Ok(())
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicUsize;
@@ -27,12 +28,17 @@ use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_http_client::HttpClientBuilder;
use codex_rmcp_client::McpOAuthCallbackMode;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::stdio_server_bin;
use flate2::Compression;
@@ -51,10 +57,12 @@ use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use serde_json::json;
use tempfile::TempDir;
use test_case::test_case;
use tokio::io::AsyncBufReadExt;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use url::Url;
use wiremock::Match;
use wiremock::Mock;
use wiremock::MockServer;
@@ -1776,7 +1784,10 @@ async fn plugin_install_starts_mcp_oauth_through_configured_http_proxy() -> Resu
"sample-mcp": {
"type": "http",
"url": format!("{resource_url}/mcp"),
"oauth": {"callbackPort": plugin_callback_port},
"oauth": {
"callbackPort": plugin_callback_port,
"callbackUrl": "http://127.0.0.1/plugin/callback",
},
}
}
}))?,
@@ -1833,12 +1844,201 @@ async fn plugin_install_starts_mcp_oauth_through_configured_http_proxy() -> Resu
.as_str()
.expect("OAuth client registration redirect URI")
.parse()?;
let expected_redirect_uri: Uri =
format!("http://127.0.0.1:{plugin_callback_port}/plugin/callback/Jb0pRxZ4-luq").parse()?;
assert_eq!(redirect_uri, expected_redirect_uri);
Ok(())
}
#[test_case(false, false, false; "legacy provider falls back to default callback")]
#[test_case(false, true, false; "legacy provider falls back to global callback")]
#[test_case(true, true, false; "issuer bound provider preserves registered callback")]
#[test_case(false, true, true; "legacy provider preserves server specific registered callback")]
#[tokio::test]
async fn plugin_oauth_login_preserves_registered_callbacks_or_uses_legacy_fallback(
issuer_supported: bool,
use_global_callback: bool,
callback_already_server_specific: bool,
) -> Result<()> {
let oauth = MockServer::start().await;
let authorization_server = oauth.uri();
let server_url = format!("{authorization_server}/mcp");
let resource_metadata_url = format!("{authorization_server}/oauth-resource");
let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\"");
let global_callback = use_global_callback.then_some("http://127.0.0.1/global/callback");
let legacy_callback = resolve_mcp_oauth_callback_url(
&server_url,
global_callback,
McpOAuthCallbackMode::CallbackSpecific,
)?;
let callback_id = legacy_callback
.rsplit('/')
.next()
.expect("legacy callback should contain the server-specific callback ID");
let registered_callback = if callback_already_server_specific {
format!("http://127.0.0.1/callback/registered/{callback_id}")
} else {
"http://127.0.0.1/callback/registered".to_string()
};
let codex_home = TempDir::new()?;
let global_callback_config = global_callback
.map(|callback| format!("mcp_oauth_callback_url = \"{callback}\"\n"))
.unwrap_or_default();
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"mcp_oauth_credentials_store = \"file\"\n{global_callback_config}\n[features]\nplugins = true\n"
),
)?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
/*install_policy*/ None,
/*auth_policy*/ None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
std::fs::write(
repo_root.path().join("sample-plugin/.mcp.json"),
serde_json::to_vec_pretty(&json!({
"mcpServers": {
"sample-mcp": {
"type": "http",
"url": server_url,
"oauth": {
"clientId": "registered-client",
"callbackUrl": registered_callback,
},
}
}
}))?,
)?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let install_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
let _: PluginInstallResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(install_id)).await??;
// Install before exposing OAuth metadata so automatic plugin login cannot
// launch a platform browser; the explicit public login remains end-to-end.
Mock::given(method("GET"))
.and(path("/mcp"))
.respond_with(
ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge.as_str()),
)
.mount(&oauth)
.await;
Mock::given(method("GET"))
.and(path("/oauth-resource"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"resource": authorization_server,
"authorization_servers": [authorization_server],
})))
.mount(&oauth)
.await;
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": authorization_server,
"authorization_endpoint": format!("{authorization_server}/oauth/authorize"),
"token_endpoint": format!("{authorization_server}/oauth/token"),
"registration_endpoint": format!("{authorization_server}/oauth/register"),
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": issuer_supported,
})))
.mount(&oauth)
.await;
Mock::given(method("POST"))
.and(path("/oauth/register"))
.respond_with(ResponseTemplate::new(400))
.expect(0)
.mount(&oauth)
.await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "registered-plugin-token",
"token_type": "Bearer",
})))
.expect(1)
.mount(&oauth)
.await;
let login_id = mcp
.send_raw_request(
"mcpServer/oauth/login",
Some(json!({
"name": "sample-mcp",
"scopes": ["read"],
"timeoutSecs": 10,
})),
)
.await?;
let response: McpServerOauthLoginResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(login_id)).await??;
let authorization_url = Url::parse(&response.authorization_url)?;
let query: BTreeMap<_, _> = authorization_url.query_pairs().into_owned().collect();
assert_eq!(
redirect_uri
.authority()
.and_then(axum::http::uri::Authority::port_u16),
Some(plugin_callback_port)
query.get("client_id").map(String::as_str),
Some("registered-client")
);
let mut callback_url = Url::parse(&query["redirect_uri"])?;
let expected_callback = if issuer_supported || callback_already_server_specific {
registered_callback.as_str()
} else {
legacy_callback.as_str()
};
assert_eq!(callback_url.path(), Url::parse(expected_callback)?.path());
callback_url
.query_pairs_mut()
.append_pair("code", "registered-plugin-code")
.append_pair("state", &query["state"]);
if issuer_supported {
callback_url
.query_pairs_mut()
.append_pair("iss", &authorization_server);
}
HttpClientBuilder::new()
.build_direct()?
.get(callback_url)
.send()
.await?
.error_for_status()?;
let completed: McpServerOauthLoginCompletedNotification = timeout(
DEFAULT_TIMEOUT,
mcp.read_notification("mcpServer/oauthLogin/completed"),
)
.await??;
assert_eq!(
completed,
McpServerOauthLoginCompletedNotification {
name: "sample-mcp".to_string(),
thread_id: None,
success: true,
error: None,
}
);
oauth.verify().await;
Ok(())
}
+56 -16
View File
@@ -29,14 +29,17 @@ use codex_mcp::apply_http_headers_helper;
use codex_mcp::compute_auth_statuses;
use codex_mcp::discover_supported_scopes;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_callback;
use codex_mcp::resolve_oauth_scopes;
use codex_mcp::should_retry_without_scopes;
use codex_protocol::protocol::McpAuthStatus;
use codex_rmcp_client::McpOAuthCallbackMode;
use codex_rmcp_client::McpOAuthClientRegistration;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::StreamableHttpRedirectMode;
use codex_rmcp_client::delete_oauth_tokens;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::format_env_display;
@@ -270,6 +273,7 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
http_client: Arc<dyn HttpClient>,
) -> Result<()> {
match perform_oauth_login(
@@ -285,6 +289,7 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource,
callback_port,
callback_url,
global_callback_url,
Arc::clone(&http_client),
)
.await
@@ -305,6 +310,7 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource,
callback_port,
callback_url,
global_callback_url,
http_client,
)
.await
@@ -346,9 +352,6 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
validate_server_name(&name)?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let mut servers = load_global_mcp_servers(&codex_home)
.await
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
let (transport, oauth_client_id, client_registration, oauth_resource) = match transport_args {
AddMcpTransportArgs {
@@ -405,6 +408,43 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),
};
// Discover once before saving so a new registered client keeps the exact
// callback its provider expects, including issuer-bound stable callbacks.
let http_client: Arc<dyn HttpClient> = Arc::new(
RouteAwareHttpClient::new(config.http_client_factory()).with_tls_backend_fallback(),
);
let login_support = oauth_login_support(
&transport,
Arc::clone(&http_client),
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.await;
// A stable callback requires confirmed issuer support. Discovery failures
// and servers without support both retain the callback-ID mix-up defense.
let callback_mode = match &login_support {
McpOAuthLoginSupport::Supported(oauth_config) => oauth_config.callback_mode,
McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => {
McpOAuthCallbackMode::CallbackSpecific
}
};
// DCR and CIMD register their callbacks during login; only an existing
// client ID has a redirect that must be persisted with the MCP server.
let callback_url = oauth_client_id
.as_ref()
.map(|_| {
let McpServerTransportConfig::StreamableHttp { url, .. } = &transport else {
bail!("OAuth client IDs require a streamable HTTP MCP server");
};
resolve_mcp_oauth_callback_url(
url,
config.mcp_oauth_callback_url.as_deref(),
callback_mode,
)
})
.transpose()?;
let new_entry = McpServerConfig {
auth: Default::default(),
transport: transport.clone(),
@@ -424,12 +464,16 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
.clone()
.map(|client_id| McpServerOAuthConfig {
client_id: Some(client_id),
callback_url: callback_url.clone(),
callback_port: None,
}),
oauth_resource: oauth_resource.clone(),
tools: HashMap::new(),
};
let mut servers = load_global_mcp_servers(&codex_home)
.await
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
servers.insert(name.clone(), new_entry);
ConfigEditsBuilder::new(&codex_home)
@@ -439,19 +483,9 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
.with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?;
println!("Added global MCP server '{name}'.");
// `mcp add` assigns new servers to the local environment, so its immediate
// OAuth discovery uses the local route-aware HTTP client.
let http_client: Arc<dyn HttpClient> = Arc::new(
RouteAwareHttpClient::new(config.http_client_factory()).with_tls_backend_fallback(),
);
let login_support = oauth_login_support(
&transport,
Arc::clone(&http_client),
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.await;
if let Some(callback_url) = &callback_url {
println!("OAuth callback URL: {callback_url}");
}
match login_support {
McpOAuthLoginSupport::Supported(oauth_config) => {
println!("Detected OAuth support. Starting OAuth flow…");
@@ -472,6 +506,9 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
client_registration,
oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
callback_url
.as_deref()
.or(config.mcp_oauth_callback_url.as_deref()),
config.mcp_oauth_callback_url.as_deref(),
http_client,
)
@@ -577,6 +614,8 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> {
let resolved_scopes =
resolve_oauth_scopes(explicit_scopes, server.scopes.clone(), discovered_scopes);
let credential_name = server.oauth_credential_name(&name);
let callback_url =
resolve_oauth_callback(server, &url, config.mcp_oauth_callback_url.as_deref())?;
perform_oauth_login_retry_without_scopes(
credential_name.as_ref(),
@@ -590,6 +629,7 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> {
client_registration,
server.oauth_resource.as_deref(),
server.oauth_callback_port(config.mcp_oauth_callback_port),
callback_url.as_deref(),
config.mcp_oauth_callback_url.as_deref(),
http_client,
)
+69 -1
View File
@@ -342,10 +342,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> {
#[tokio::test]
async fn add_streamable_http_with_oauth_options() -> Result<()> {
let codex_home = TempDir::new()?;
let expected_callback = "http://127.0.0.1/callback/w9gKTtkB7gWy";
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"-c",
"mcp_oauth_callback_port=43123",
"mcp",
"add",
"oauth-server",
@@ -357,7 +360,8 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
"https://resource.example.com",
])
.assert()
.success();
.success()
.stdout(contains(format!("OAuth callback URL: {expected_callback}")));
let servers = load_global_mcp_servers(codex_home.path()).await?;
let oauth_server = servers
@@ -367,6 +371,13 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
oauth_server.oauth_client_id(),
Some("eci-prd-pub-codex-123")
);
assert_eq!(
oauth_server
.oauth
.as_ref()
.and_then(|oauth| oauth.callback_url.as_deref()),
Some(expected_callback)
);
assert_eq!(
oauth_server.oauth_resource.as_deref(),
Some("https://resource.example.com")
@@ -375,6 +386,63 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn add_persists_issuer_bound_callback_before_starting_oauth() -> Result<()> {
let codex_home = TempDir::new()?;
let oauth_server = MockServer::start().await;
let issuer = format!("{}/mcp", oauth_server.uri());
let metadata = serde_json::json!({
"issuer": issuer,
"authorization_endpoint": "not-a-valid-authorization-url",
"token_endpoint": format!("{}/token", oauth_server.uri()),
"authorization_response_iss_parameter_supported": true,
});
let concurrent_codex_home = codex_home.path().to_path_buf();
let concurrent_add = std::sync::Once::new();
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(move |_: &wiremock::Request| {
concurrent_add.call_once(|| {
codex_command(&concurrent_codex_home)
.expect("create concurrent MCP add command")
.args(["mcp", "add", "concurrent", "--", "echo", "concurrent"])
.assert()
.success();
});
ResponseTemplate::new(200).set_body_json(metadata.clone())
})
.mount(&oauth_server)
.await;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd.args([
"mcp",
"add",
"issuer-bound",
"--url",
&format!("{}/mcp", oauth_server.uri()),
"--oauth-client-id",
"registered-client",
]);
let output = tokio::task::spawn_blocking(move || add_cmd.output()).await??;
assert!(!output.status.success());
assert!(
String::from_utf8(output.stdout)?.contains("OAuth callback URL: http://127.0.0.1/callback")
);
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.contains_key("concurrent"));
assert_eq!(
servers["issuer-bound"]
.oauth
.as_ref()
.and_then(|oauth| oauth.callback_url.as_deref()),
Some("http://127.0.0.1/callback")
);
Ok(())
}
#[tokio::test]
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
let codex_home = TempDir::new()?;
+1
View File
@@ -83,6 +83,7 @@ pub use mcp::ResolvedMcpOAuthScopes;
pub use mcp::compute_auth_statuses;
pub use mcp::discover_supported_scopes;
pub use mcp::oauth_login_support;
pub use mcp::resolve_oauth_callback;
pub use mcp::resolve_oauth_scopes;
pub use mcp::should_retry_without_scopes;
+80
View File
@@ -10,12 +10,14 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
use codex_login::CodexAuth;
use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::McpOAuthCallbackMode;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::OAuthProviderError;
use codex_rmcp_client::StreamableHttpRedirectMode;
use codex_rmcp_client::determine_streamable_http_auth_status;
use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials;
use codex_rmcp_client::discover_streamable_http_oauth;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use futures::FutureExt;
use futures::future::join_all;
use tracing::warn;
@@ -30,6 +32,7 @@ pub struct McpOAuthLoginConfig {
pub http_headers: Option<HashMap<String, String>>,
pub env_http_headers: Option<HashMap<String, String>>,
pub discovered_scopes: Option<Vec<String>>,
pub callback_mode: McpOAuthCallbackMode,
}
#[derive(Debug)]
@@ -53,6 +56,35 @@ pub struct ResolvedMcpOAuthScopes {
pub source: McpOAuthScopesSource,
}
/// Keeps registered callbacks tied to their client while preserving legacy redirects.
pub fn resolve_oauth_callback(
server: &McpServerConfig,
server_url: &str,
global_callback_url: Option<&str>,
) -> Result<Option<String>> {
if let Some(callback_url) = server
.oauth
.as_ref()
.and_then(|oauth| oauth.callback_url.as_deref())
{
return Ok(Some(callback_url.to_string()));
}
if server
.oauth_client_id()
.is_none_or(|client_id| client_id.trim().is_empty())
{
return Ok(global_callback_url.map(ToOwned::to_owned));
}
resolve_mcp_oauth_callback_url(
server_url,
global_callback_url,
McpOAuthCallbackMode::CallbackSpecific,
)
.map(Some)
}
#[derive(Debug, Clone)]
pub struct McpAuthStatusEntry {
pub config: Option<McpServerConfig>,
@@ -80,6 +112,7 @@ pub async fn oauth_login_support(
{
Ok(Some(discovery)) => {
config.discovered_scopes = discovery.scopes_supported;
config.callback_mode = discovery.callback_mode;
McpOAuthLoginSupport::Supported(config)
}
Ok(None) => McpOAuthLoginSupport::Unsupported,
@@ -106,6 +139,7 @@ fn oauth_login_candidate(transport: &McpServerTransportConfig) -> Option<McpOAut
http_headers: http_headers.clone(),
env_http_headers: env_http_headers.clone(),
discovered_scopes: None,
callback_mode: McpOAuthCallbackMode::CallbackSpecific,
})
}
@@ -300,9 +334,55 @@ mod tests {
use super::McpOAuthScopesSource;
use super::OAuthProviderError;
use super::ResolvedMcpOAuthScopes;
use super::resolve_oauth_callback;
use super::resolve_oauth_scopes;
use super::should_retry_without_scopes;
#[test]
fn callback_resolution_preserves_registered_and_legacy_clients() -> anyhow::Result<()> {
for (client_id, saved_callback, global_callback, expected_callback) in [
(
Some("registered-client"),
Some("http://127.0.0.1/callback"),
Some("https://override.example/callback"),
Some("http://127.0.0.1/callback"),
),
(
Some("legacy-client"),
None,
None,
Some("http://127.0.0.1/callback/epMNJ6P1xGQ9"),
),
(
None,
Some("https://plugin.example/callback"),
Some("https://override.example/callback"),
Some("https://plugin.example/callback"),
),
(
None,
None,
Some("https://override.example/callback"),
Some("https://override.example/callback"),
),
] {
let server = serde_json::from_value(serde_json::json!({
"url": "https://mcp.example.com/mcp",
"oauth": {
"client_id": client_id,
"callback_url": saved_callback,
},
}))?;
assert_eq!(
resolve_oauth_callback(&server, "https://mcp.example.com/mcp", global_callback)?
.as_deref(),
expected_callback
);
}
Ok(())
}
#[test]
fn resolve_oauth_scopes_prefers_explicit() {
let resolved = resolve_oauth_scopes(
+1
View File
@@ -6,6 +6,7 @@ pub use auth::ResolvedMcpOAuthScopes;
pub use auth::compute_auth_statuses;
pub use auth::discover_supported_scopes;
pub use auth::oauth_login_support;
pub use auth::resolve_oauth_callback;
pub use auth::resolve_oauth_scopes;
pub use auth::should_retry_without_scopes;
+6
View File
@@ -257,6 +257,12 @@ fn normalize_plugin_mcp_server_value(
}
if let Some(JsonValue::Object(mut oauth)) = object.remove("oauth") {
if let Some(callback_url) = oauth.remove("callbackUrl") {
oauth
.entry("callback_url".to_string())
.or_insert(callback_url);
}
if let Some(callback_port) = oauth.remove("callbackPort") {
oauth
.entry("callback_port".to_string())
@@ -582,6 +582,7 @@ fn declared_placement_preserves_local_plugin_normalization() {
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("client-id".to_string()),
callback_url: Some("http://127.0.0.1/callback/registered".to_string()),
callback_port: Some(9876),
}),
oauth_resource: None,
@@ -611,7 +612,7 @@ fn declared_placement_preserves_local_plugin_normalization() {
"hosted": {
"type": "http",
"url": "https://example.com/mcp",
"oauth": {"clientId": "client-id", "callbackPort": 9876}
"oauth": {"clientId": "client-id", "callbackUrl": "http://127.0.0.1/callback/registered", "callbackPort": 9876}
},
"helper": {"type":"http","url":"https://example.com/mcp","http_headers_helper":"./auth.sh"}
}"#,
+4
View File
@@ -146,6 +146,10 @@ pub struct McpServerOAuthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
/// Registered callback URL associated with this OAuth client.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback_url: Option<String>,
/// Fixed callback port that takes precedence over Codex's global OAuth callback port.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback_port: Option<u16>,
+2
View File
@@ -364,6 +364,7 @@ fn deserialize_streamable_http_server_config_with_oauth_client_id() {
[oauth]
client_id = "eci-prd-pub-codex-123"
callback_url = "http://127.0.0.1/callback/registered"
callback_port = 9876
"#,
)
@@ -373,6 +374,7 @@ fn deserialize_streamable_http_server_config_with_oauth_client_id() {
cfg.oauth,
Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: Some("http://127.0.0.1/callback/registered".to_string()),
callback_port: Some(9876),
})
);
@@ -982,6 +982,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("client-id".to_string()),
callback_url: None,
callback_port: Some(3118),
}),
oauth_resource: None,
+4
View File
@@ -1920,6 +1920,10 @@
"minimum": 0.0,
"type": "integer"
},
"callback_url": {
"description": "Registered callback URL associated with this OAuth client.",
"type": "string"
},
"client_id": {
"description": "Explicit OAuth client identifier to present during authorization and token exchange.",
"type": "string"
+1
View File
@@ -7830,6 +7830,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: None,
callback_port: None,
}),
oauth_resource: Some("https://resource.example.com".to_string()),
@@ -155,6 +155,9 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
{
oauth_table["client_id"] = value(client_id.clone());
}
if let Some(callback_url) = &oauth.callback_url {
oauth_table["callback_url"] = value(callback_url.clone());
}
if let Some(callback_port) = oauth.callback_port {
oauth_table["callback_port"] = value(i64::from(callback_port));
}
+2
View File
@@ -1087,6 +1087,7 @@ fn blocking_replace_mcp_servers_round_trips() {
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
callback_url: Some("http://127.0.0.1/callback/example".to_string()),
callback_port: Some(9876),
}),
oauth_resource: Some("https://resource.example.com".to_string()),
@@ -1116,6 +1117,7 @@ Z-Header = \"z\"
[mcp_servers.http.oauth]
client_id = \"eci-prd-pub-codex-123\"
callback_url = \"http://127.0.0.1/callback/example\"
callback_port = 9876
[mcp_servers.stdio]
@@ -205,6 +205,7 @@ async fn maybe_install_mcp_dependencies(
server_config.oauth_resource.as_deref(),
callback_port,
config.mcp_oauth_callback_url.as_deref(),
config.mcp_oauth_callback_url.as_deref(),
Arc::clone(&http_client),
)
.await;
@@ -224,6 +225,7 @@ async fn maybe_install_mcp_dependencies(
server_config.oauth_resource.as_deref(),
callback_port,
config.mcp_oauth_callback_url.as_deref(),
config.mcp_oauth_callback_url.as_deref(),
Arc::clone(&http_client),
)
.await
@@ -427,6 +429,7 @@ fn mcp_dependency_to_server_config(
.oauth_callback_port
.map(|callback_port| McpServerOAuthConfig {
client_id: None,
callback_url: None,
callback_port: Some(callback_port),
}),
oauth_resource: None,
@@ -416,6 +416,7 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()>
.and_then(|server| server.oauth.as_ref()),
Some(&McpServerOAuthConfig {
client_id: None,
callback_url: None,
callback_port: Some(skill_callback_port),
})
);
+48 -5
View File
@@ -15,6 +15,8 @@ use tracing::debug;
use crate::http_client_adapter::StreamableHttpRedirectMode;
use crate::oauth::StoredOAuthTokenStatus;
use crate::oauth::oauth_token_status;
use crate::oauth_callback::McpOAuthCallbackMode;
use crate::oauth_callback::callback_mode;
use crate::oauth_http_client::OAuthHttpClientAdapter;
use crate::utils::build_default_headers;
use codex_config::types::AuthKeyringBackendKind;
@@ -39,6 +41,7 @@ impl OAuthDiscoveryTimeout {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamableHttpOAuthDiscovery {
pub scopes_supported: Option<Vec<String>>,
pub callback_mode: McpOAuthCallbackMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -263,9 +266,14 @@ async fn discover_streamable_http_oauth_with_manager(
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
match authorization_manager.resolve_metadata().boxed().await {
Ok(resolution) if !resolution.source.is_discovered() => Ok(None),
Ok(resolution) => Ok(Some(StreamableHttpOAuthDiscovery {
scopes_supported: normalize_scopes(resolution.metadata.scopes_supported),
})),
Ok(resolution) => {
let metadata = resolution.metadata;
Ok(Some(StreamableHttpOAuthDiscovery {
callback_mode: callback_mode(&metadata)
.unwrap_or(McpOAuthCallbackMode::CallbackSpecific),
scopes_supported: normalize_scopes(metadata.scopes_supported),
}))
}
Err(AuthError::NoAuthorizationSupport) => Ok(None),
Err(err) => Err(err.into()),
}
@@ -668,6 +676,7 @@ mod tests {
let server = spawn_oauth_discovery_server(serde_json::json!({
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"authorization_response_iss_parameter_supported": true,
"scopes_supported": ["profile", " email ", "profile", "", " "],
}))
.await;
@@ -685,8 +694,42 @@ mod tests {
.expect("oauth support should be detected");
assert_eq!(
discovery.scopes_supported,
Some(vec!["profile".to_string(), "email".to_string()])
discovery,
StreamableHttpOAuthDiscovery {
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
callback_mode: McpOAuthCallbackMode::IssuerBound,
}
);
}
#[tokio::test]
async fn issuer_support_without_a_metadata_issuer_falls_back_to_distinct_callbacks() {
let server = spawn_oauth_discovery_server(serde_json::json!({
"issuer": null,
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"authorization_response_iss_parameter_supported": true,
}))
.await;
let discovery = discover_streamable_http_oauth(
&server.url,
/*http_headers*/ None,
/*env_http_headers*/ None,
test_http_client(),
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.await
.expect("discovery should succeed")
.expect("oauth support should be detected");
assert_eq!(
discovery,
StreamableHttpOAuthDiscovery {
scopes_supported: None,
callback_mode: McpOAuthCallbackMode::CallbackSpecific,
}
);
}
+3
View File
@@ -9,6 +9,7 @@ mod in_process_transport;
mod local_stdio_transport;
mod logging_client_handler;
mod oauth;
mod oauth_callback;
mod oauth_client_registration;
mod oauth_http_client;
mod perform_oauth_login;
@@ -38,6 +39,8 @@ pub use oauth::delete_oauth_tokens;
pub use oauth::save_oauth_tokens;
pub use oauth::stored_oauth_credential_snapshot;
pub use oauth::stored_oauth_credentials;
pub use oauth_callback::McpOAuthCallbackMode;
pub use oauth_callback::resolve_mcp_oauth_callback_url;
pub use oauth_client_registration::McpOAuthClientRegistration;
pub use perform_oauth_login::OAuthProviderError;
pub use perform_oauth_login::OauthLoginHandle;
+148
View File
@@ -0,0 +1,148 @@
//! OAuth callback identity and authorization-server mix-up protection.
//!
//! Codex can authorize against many independent MCP servers. If those servers
//! share a callback URL and a response does not identify its authorization
//! server, Codex could associate an authorization code with the wrong server
//! and send that code to an attacker-controlled token endpoint. RFC 9700 calls
//! this an authorization-server mix-up attack:
//! https://www.rfc-editor.org/rfc/rfc9700#section-4.4
//!
//! MCP prefers issuer identification: authorization servers SHOULD include
//! `iss` in authorization responses; servers that include `iss` MUST advertise
//! that support, and clients MUST validate any returned issuer before
//! exchanging the code. This also lets a CIMD document advertise one stable
//! redirect instead of separate redirects for every authorization server:
//! https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#authorization-response-validation
//!
//! This module prefers issuer binding when available and otherwise retains a
//! callback-specific compatibility fallback:
//!
//! - `IssuerBound`: reuse a stable callback only when authorization metadata
//! advertises `authorization_response_iss_parameter_supported` and contains
//! its issuer. RMCP validates the response's `iss` against that metadata
//! issuer before exchanging the authorization code, rejecting missing or
//! mismatched issuers:
//! https://www.rfc-editor.org/rfc/rfc9700#section-4.4.2.1
//! https://www.rfc-editor.org/rfc/rfc9207#section-2.4
//! - `CallbackSpecific`: append an ID derived from the complete MCP server URL.
//! Distinct callback paths bind each response to its intended server. This is
//! the required fallback when issuer identification is not supported:
//! https://www.rfc-editor.org/rfc/rfc9700#section-4.4.2.2
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rmcp::transport::auth::AuthorizationMetadata;
use sha2::Digest;
use sha2::Sha256;
use url::Url;
/// The OAuth mix-up defense associated with a newly registered callback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpOAuthCallbackMode {
/// A server-specific redirect path identifies the authorization server.
CallbackSpecific,
/// A validated authorization-response issuer permits a shared redirect.
IssuerBound,
}
pub(crate) fn callback_mode(metadata: &AuthorizationMetadata) -> Result<McpOAuthCallbackMode> {
let issuer_response_supported = metadata
.additional_fields
.get("authorization_response_iss_parameter_supported")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !issuer_response_supported {
return Ok(McpOAuthCallbackMode::CallbackSpecific);
}
if metadata.issuer.is_none() {
bail!("OAuth authorization server advertises issuer support without a metadata issuer");
}
Ok(McpOAuthCallbackMode::IssuerBound)
}
/// Resolves the registered callback independently of runtime listener ports.
pub fn resolve_mcp_oauth_callback_url(
server_url: &str,
callback_url: Option<&str>,
callback_mode: McpOAuthCallbackMode,
) -> Result<String> {
let callback_url = callback_url.unwrap_or("http://127.0.0.1/callback");
match callback_mode {
McpOAuthCallbackMode::IssuerBound => {
Url::parse(callback_url)
.with_context(|| format!("invalid redirect URI `{callback_url}`"))?;
Ok(callback_url.to_string())
}
McpOAuthCallbackMode::CallbackSpecific => {
let callback_id = callback_id_from_server_url(server_url)?;
append_callback_id_to_redirect_uri(callback_url, &callback_id)
}
}
}
pub(crate) fn callback_id_from_server_url(server_url: &str) -> Result<String> {
// Native Codex callback IDs intentionally hash the complete MCP URL (minus its fragment)
// with SHA-256. Python connector callback IDs use SHAKE-256 over the origin and are distinct.
let mut parsed =
Url::parse(server_url).with_context(|| format!("invalid MCP server URL `{server_url}`"))?;
parsed
.host_str()
.ok_or_else(|| anyhow!("MCP server URL `{server_url}` must include a host"))?;
parsed.set_fragment(None);
let digest = Sha256::digest(parsed.as_str().as_bytes());
Ok(URL_SAFE_NO_PAD.encode(&digest[..9]))
}
pub(crate) fn append_callback_id_to_redirect_uri(
redirect_uri: &str,
callback_id: &str,
) -> Result<String> {
let mut parsed = Url::parse(redirect_uri)
.with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?;
if parsed
.path_segments()
.and_then(|mut segments| segments.next_back())
== Some(callback_id)
{
return Ok(parsed.to_string());
}
let path = parsed.path();
let new_path = if path.ends_with('/') {
format!("{path}{callback_id}")
} else {
format!("{path}/{callback_id}")
};
parsed.set_path(&new_path);
Ok(parsed.to_string())
}
pub(crate) fn validate_callback_redirect(
redirect_uri: &str,
callback_id: &str,
callback_mode: McpOAuthCallbackMode,
) -> Result<()> {
let has_expected_callback_id = Url::parse(redirect_uri)?
.path_segments()
.and_then(|mut segments| segments.next_back())
== Some(callback_id);
if !has_expected_callback_id && callback_mode != McpOAuthCallbackMode::IssuerBound {
bail!(
"OAuth callback requires its expected callback ID or authorization response issuer support"
);
}
Ok(())
}
#[cfg(test)]
#[path = "oauth_callback_tests.rs"]
mod tests;
@@ -0,0 +1,63 @@
use pretty_assertions::assert_eq;
use super::McpOAuthCallbackMode;
use super::callback_id_from_server_url;
use super::resolve_mcp_oauth_callback_url;
use super::validate_callback_redirect;
#[test]
fn resolved_callbacks_follow_the_selected_mix_up_defense() {
let server_url = "https://mcp.example.com/mcp?tenant=one";
let callback_id = callback_id_from_server_url(server_url).expect("resolve callback ID");
let distinct_callback = format!("http://127.0.0.1/callback/{callback_id}");
for (callback, mode, expected) in [
(
None,
McpOAuthCallbackMode::CallbackSpecific,
distinct_callback.as_str(),
),
(
None,
McpOAuthCallbackMode::IssuerBound,
"http://127.0.0.1/callback",
),
(
Some("http://127.0.0.1:8080/oauth/callback"),
McpOAuthCallbackMode::IssuerBound,
"http://127.0.0.1:8080/oauth/callback",
),
] {
assert_eq!(
resolve_mcp_oauth_callback_url(server_url, callback, mode)
.expect("resolve registered callback"),
expected
);
}
}
#[test]
fn callback_redirect_requires_a_server_specific_id_or_issuer_support() {
for (redirect_uri, mode, expected_valid) in [
(
"http://127.0.0.1/callback/expected-id",
McpOAuthCallbackMode::CallbackSpecific,
true,
),
(
"http://127.0.0.1/callback",
McpOAuthCallbackMode::IssuerBound,
true,
),
(
"http://127.0.0.1/callback/wrong-id",
McpOAuthCallbackMode::CallbackSpecific,
false,
),
] {
assert_eq!(
validate_callback_redirect(redirect_uri, "expected-id", mode).is_ok(),
expected_valid
);
}
}
@@ -10,6 +10,10 @@ use rmcp::transport::auth::OAuthState;
use url::Url;
use crate::oauth::validate_authorization_server_endpoints;
use crate::oauth_callback::McpOAuthCallbackMode;
use crate::oauth_callback::append_callback_id_to_redirect_uri;
use crate::oauth_callback::callback_mode;
use crate::oauth_callback::validate_callback_redirect;
/// OAuth client-registration strategy for one interactive HTTP MCP login.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -27,6 +31,7 @@ pub enum McpOAuthClientRegistration {
pub(crate) struct PreparedOAuthLogin {
pub(crate) oauth_state: OAuthState,
pub(crate) authorization_server_issuer: Option<String>,
pub(crate) redirect_uri: String,
}
pub(crate) async fn start_authorization(
@@ -43,6 +48,7 @@ pub(crate) async fn start_authorization(
let metadata = auth_manager.resolve_metadata().await?.metadata;
validate_authorization_server_endpoints(&metadata)?;
let authorization_server_issuer = metadata.issuer.clone();
let callback_mode = callback_mode(&metadata)?;
let cimd_advertised = metadata
.additional_fields
@@ -55,18 +61,30 @@ pub(crate) async fn start_authorization(
.and_then(serde_json::Value::as_array)
.is_some_and(|methods| methods.iter().any(|method| method.as_str() == Some("none")));
let parsed_redirect_uri = Url::parse(redirect_uri)?;
let uses_shared_callback = callback_mode == McpOAuthCallbackMode::IssuerBound;
let redirect_uri = if uses_shared_callback {
redirect_uri.to_string()
} else {
append_callback_id_to_redirect_uri(redirect_uri, callback_id)?
};
let parsed_redirect_uri = Url::parse(&redirect_uri)?;
let expected_callback_path = if uses_shared_callback {
"/callback".to_string()
} else {
format!("/callback/{callback_id}")
};
let native_redirect_supported = parsed_redirect_uri.scheme() == "http"
&& matches!(
parsed_redirect_uri.host_str(),
Some("127.0.0.1" | "localhost")
)
&& parsed_redirect_uri.port().is_some_and(|port| port > 0)
&& parsed_redirect_uri.path() == format!("/callback/{callback_id}")
&& parsed_redirect_uri.path() == expected_callback_path
&& parsed_redirect_uri.query().is_none()
&& parsed_redirect_uri.fragment().is_none()
&& parsed_redirect_uri.username().is_empty()
&& parsed_redirect_uri.password().is_none();
validate_callback_redirect(&redirect_uri, callback_id, callback_mode)?;
// MCP 2026-07-28 priority: pre-registered clients never reach this path; offer
// advertised CIMD here and otherwise let rmcp fall back to DCR.
// https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
@@ -82,7 +100,7 @@ pub(crate) async fn start_authorization(
}
if !native_redirect_supported {
bail!(
"MCP OAuth CIMD requires an ephemeral loopback callback at `/callback/{callback_id}`"
"MCP OAuth CIMD requires an ephemeral loopback callback at `{expected_callback_path}`"
);
}
true
@@ -91,16 +109,19 @@ pub(crate) async fn start_authorization(
};
auth_manager.set_metadata(metadata);
let mut request = AuthorizationRequest::new(redirect_uri)
let mut request = AuthorizationRequest::new(redirect_uri.clone())
.with_scopes(scopes.iter().copied())
.with_client_name("Codex");
if offer_cimd {
// CIMD is an active IETF Internet-Draft: this HTTPS client identifier resolves
// to its self-referential JSON metadata document.
// https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
request = request.with_client_metadata_url(format!(
"https://chatgpt.com/oauth/codex/{callback_id}/client.json"
));
let client_metadata_url = if uses_shared_callback {
"https://chatgpt.com/oauth/codex/client.json".to_string()
} else {
format!("https://chatgpt.com/oauth/codex/{callback_id}/client.json")
};
request = request.with_client_metadata_url(client_metadata_url);
}
let session = AuthorizationSession::new(auth_manager, request)
.await
@@ -109,6 +130,7 @@ pub(crate) async fn start_authorization(
Ok(PreparedOAuthLogin {
oauth_state: OAuthState::Session(session),
authorization_server_issuer,
redirect_uri,
})
}
@@ -128,20 +128,47 @@ async fn authorization(
}
#[tokio::test]
async fn automatic_cimd_uses_callback_specific_identity() -> Result<()> {
for host in ["127.0.0.1", "localhost"] {
let server = oauth_server(json!({})).await;
let redirect = format!("http://{host}:43123/callback/{CALLBACK_ID}");
async fn automatic_cimd_uses_stable_or_callback_specific_identity() -> Result<()> {
for (host, supports_issuer, expected_client_id, expected_redirect) in [
(
"127.0.0.1",
false,
"https://chatgpt.com/oauth/codex/abc123ABC_-x/client.json",
"http://127.0.0.1:43123/callback/abc123ABC_-x",
),
(
"localhost",
false,
"https://chatgpt.com/oauth/codex/abc123ABC_-x/client.json",
"http://localhost:43123/callback/abc123ABC_-x",
),
(
"127.0.0.1",
true,
"https://chatgpt.com/oauth/codex/client.json",
"http://127.0.0.1:43123/callback",
),
] {
let server = oauth_server(json!({
"authorization_response_iss_parameter_supported": supports_issuer,
}))
.await;
let redirect = format!("http://{host}:43123/callback");
let (mut state, query) =
authorization(&server, &redirect, McpOAuthClientRegistration::Auto).await?;
let expected_id = format!("https://chatgpt.com/oauth/codex/{CALLBACK_ID}/client.json");
assert_eq!(query["client_id"], expected_id);
assert_eq!(query["redirect_uri"], redirect);
assert_eq!(query["client_id"], expected_client_id);
assert_eq!(query["redirect_uri"], expected_redirect);
assert_eq!(query["code_challenge_method"], "S256");
assert_eq!(query["scope"], "read offline_access");
state
.handle_callback_with_issuer("valid-authorization-code", &query["state"], None)
.handle_callback_with_issuer(
"valid-authorization-code",
&query["state"],
supports_issuer
.then(|| format!("{}/mcp", server.uri()))
.as_deref(),
)
.await?;
let token_requests = requests_to(&server, "/token").await;
assert_eq!(token_requests.len(), 1);
@@ -149,8 +176,8 @@ async fn automatic_cimd_uses_callback_specific_identity() -> Result<()> {
let body: HashMap<_, _> = url::form_urlencoded::parse(&request.body)
.into_owned()
.collect();
assert_eq!(body["client_id"], expected_id);
assert_eq!(body["redirect_uri"], redirect);
assert_eq!(body["client_id"], expected_client_id);
assert_eq!(body["redirect_uri"], expected_redirect);
assert_eq!(body["grant_type"], "authorization_code");
assert!(body.contains_key("code_verifier"));
assert!(!body.contains_key("client_secret"));
@@ -170,6 +197,7 @@ async fn automatic_cimd_uses_callback_specific_identity() -> Result<()> {
#[tokio::test]
async fn registration_selection_preserves_dcr_capabilities_and_exact_redirects() -> Result<()> {
let native = "http://localhost:43123/callback/abc123ABC_-x";
let shared_native = "http://localhost:43123/callback";
let custom = "https://callbacks.example.com/oauth/callback/abc123ABC_-x";
for (metadata, redirect, registration, expected_redirect) in [
(
@@ -193,9 +221,9 @@ async fn registration_selection_preserves_dcr_capabilities_and_exact_redirects()
(json!({}), custom, McpOAuthClientRegistration::Auto, custom),
(
json!({"authorization_response_iss_parameter_supported": true}),
native,
shared_native,
McpOAuthClientRegistration::Dcr,
native,
shared_native,
),
] {
let server = oauth_server(metadata).await;
@@ -485,6 +513,14 @@ async fn resource_headers_follow_same_origin_registration_redirect_and_sdk_auth_
async fn invalid_cimd_metadata_and_redirects_fail_without_dynamic_registration() {
let valid = "http://127.0.0.1:43123/callback/abc123ABC_-x";
for (metadata, redirect, expected_error) in [
(
json!({
"authorization_response_iss_parameter_supported": true,
"issuer": null,
}),
valid,
"issuer-bound callbacks require an authorization server issuer",
),
(
json!({"token_endpoint_auth_methods_supported": ["private_key_jwt"]}),
valid,
+189 -68
View File
@@ -7,16 +7,13 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_exec_server::HttpClient;
use rmcp::transport::AuthorizationManager;
use rmcp::transport::AuthorizationSession;
use rmcp::transport::auth::AuthorizationMetadata;
use rmcp::transport::auth::OAuthClientConfig;
use rmcp::transport::auth::OAuthHttpClient;
use rmcp::transport::auth::OAuthState;
use sha2::Digest;
use sha2::Sha256;
use tiny_http::Response;
use tiny_http::Server;
use tokio::sync::oneshot;
@@ -29,6 +26,11 @@ use crate::WrappedOAuthTokenResponse;
use crate::http_client_adapter::StreamableHttpRedirectMode;
use crate::oauth::compute_expires_at_millis;
use crate::oauth::validate_authorization_server_endpoints;
use crate::oauth_callback::McpOAuthCallbackMode;
use crate::oauth_callback::append_callback_id_to_redirect_uri;
use crate::oauth_callback::callback_id_from_server_url;
use crate::oauth_callback::callback_mode;
use crate::oauth_callback::validate_callback_redirect;
use crate::oauth_client_registration::McpOAuthClientRegistration;
use crate::oauth_client_registration::PreparedOAuthLogin;
use crate::oauth_client_registration::start_authorization as start_client_registration;
@@ -99,6 +101,7 @@ pub async fn perform_oauth_login(
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
http_client: Arc<dyn HttpClient>,
) -> Result<()> {
perform_oauth_login_with_browser_output(
@@ -114,6 +117,7 @@ pub async fn perform_oauth_login(
oauth_resource,
callback_port,
callback_url,
global_callback_url,
http_client,
/*emit_browser_url*/ true,
StreamableHttpRedirectMode::Legacy,
@@ -135,6 +139,7 @@ pub async fn perform_oauth_login_silent(
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
http_client: Arc<dyn HttpClient>,
redirect_mode: StreamableHttpRedirectMode,
) -> Result<()> {
@@ -151,6 +156,7 @@ pub async fn perform_oauth_login_silent(
oauth_resource,
callback_port,
callback_url,
global_callback_url,
http_client,
/*emit_browser_url*/ false,
redirect_mode,
@@ -172,6 +178,7 @@ async fn perform_oauth_login_with_browser_output(
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
http_client: Arc<dyn HttpClient>,
emit_browser_url: bool,
redirect_mode: StreamableHttpRedirectMode,
@@ -195,6 +202,7 @@ async fn perform_oauth_login_with_browser_output(
/*launch_browser*/ true,
callback_port,
callback_url,
global_callback_url,
/*timeout_secs*/ None,
)
.await?
@@ -217,6 +225,7 @@ pub async fn perform_oauth_login_return_url(
timeout_secs: Option<i64>,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
http_client: Arc<dyn HttpClient>,
redirect_mode: StreamableHttpRedirectMode,
) -> Result<OauthLoginHandle> {
@@ -239,6 +248,7 @@ pub async fn perform_oauth_login_return_url(
/*launch_browser*/ false,
callback_port,
callback_url,
global_callback_url,
timeout_secs,
)
.await?;
@@ -443,38 +453,32 @@ fn resolve_redirect_uri(server: &Server, callback_url: Option<&str>) -> Result<S
let Some(callback_url) = callback_url else {
return local_redirect_uri(server);
};
Url::parse(callback_url)
let mut parsed = Url::parse(callback_url)
.with_context(|| format!("invalid MCP OAuth callback URL `{callback_url}`"))?;
// Registered loopback callbacks omit the temporary listener port because
// the OS can assign a different port on every login. Add the active port
// only to this authorization request; RFC 8252 requires authorization
// servers to accept any request-time port for loopback IP redirects.
// https://www.rfc-editor.org/rfc/rfc8252#section-7.3
if parsed.scheme() == "http"
&& parsed.host_str() == Some("127.0.0.1")
&& parsed.port().is_none()
{
let listener_port = server
.server_addr()
.to_ip()
.ok_or_else(|| anyhow!("unable to determine OAuth callback listener port"))?
.port();
parsed
.set_port(Some(listener_port))
.map_err(|()| anyhow!("unable to set OAuth callback listener port"))?;
return Ok(parsed.to_string());
}
Ok(callback_url.to_string())
}
fn callback_id_from_server_url(server_url: &str) -> Result<String> {
// Native Codex callback IDs intentionally hash the complete MCP URL (minus its fragment)
// with SHA-256. Python connector callback IDs use SHAKE-256 over the origin and are distinct.
let mut parsed =
Url::parse(server_url).with_context(|| format!("invalid MCP server URL `{server_url}`"))?;
parsed
.host_str()
.ok_or_else(|| anyhow!("MCP server URL `{server_url}` must include a host"))?;
parsed.set_fragment(None);
let digest = Sha256::digest(parsed.as_str().as_bytes());
Ok(URL_SAFE_NO_PAD.encode(&digest[..9]))
}
fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) -> Result<String> {
let mut parsed = Url::parse(redirect_uri)
.with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?;
let path = parsed.path();
let new_path = if path.ends_with('/') {
format!("{path}{callback_id}")
} else {
format!("{path}/{callback_id}")
};
parsed.set_path(&new_path);
Ok(parsed.to_string())
}
fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result<String> {
let parsed = Url::parse(redirect_uri)
.with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?;
@@ -511,29 +515,24 @@ impl OauthLoginFlow {
launch_browser: bool,
callback_port: Option<u16>,
callback_url: Option<&str>,
global_callback_url: Option<&str>,
timeout_secs: Option<i64>,
) -> Result<Self> {
const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300;
let bind_host = callback_bind_host(callback_url);
let callback_port = resolve_callback_port(callback_port)?;
let bind_addr = match callback_port {
Some(port) => format!("{bind_host}:{port}"),
None => format!("{bind_host}:0"),
};
let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
let guard = CallbackServerGuard {
server: Arc::clone(&server),
};
let redirect_uri = resolve_redirect_uri(&server, callback_url)?;
let callback_id = callback_id_from_server_url(server_url)?;
let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?;
let callback_path = callback_path_from_redirect_uri(&redirect_uri)?;
let (tx, rx) = oneshot::channel();
spawn_callback_server(server, tx, callback_path);
let oauth_client_id = oauth_client_id.filter(|client_id| !client_id.trim().is_empty());
let configured_callback = if oauth_client_id.is_some() {
callback_url
.map(|callback_url| {
Url::parse(callback_url)
.with_context(|| format!("invalid MCP OAuth callback URL `{callback_url}`"))
})
.transpose()?
} else {
None
};
let OAuthHttpContext {
http_headers,
@@ -548,26 +547,75 @@ impl OauthLoginFlow {
.as_ref()
.is_some_and(|headers| !headers.is_empty());
let default_headers = build_default_headers(http_headers, env_http_headers)?;
let oauth_http_client = Arc::new(OAuthHttpClientAdapter::new_with_redirect_mode(
http_client,
default_headers,
server_url,
has_configured_headers,
redirect_mode,
)?);
let oauth_http_client: Arc<dyn OAuthHttpClient> =
Arc::new(OAuthHttpClientAdapter::new_with_redirect_mode(
http_client,
default_headers,
server_url,
has_configured_headers,
redirect_mode,
)?);
let registered_authorization = if oauth_client_id.is_some() {
Some(resolve_authorization_manager(server_url, Arc::clone(&oauth_http_client)).await?)
} else {
None
};
let registered_callback_mode = registered_authorization
.as_ref()
.map(|(_, metadata)| callback_mode(metadata))
.transpose()?;
let use_legacy_fallback = registered_callback_mode
== Some(McpOAuthCallbackMode::CallbackSpecific)
&& configured_callback.as_ref().is_some_and(|callback_url| {
callback_url
.path_segments()
.and_then(|mut segments| segments.next_back())
!= Some(callback_id.as_str())
});
let callback_url = if use_legacy_fallback {
// Any preregistered client's callback can lack its required ID when
// the authorization server does not support issuer binding. This
// especially affects plugins, whose callbacks are configured before
// metadata discovery. Preserve compatibility and avoid making every
// login fail by using the global/default callback instead; its
// required server-specific callback ID is appended below.
global_callback_url
} else {
callback_url
};
let bind_host = callback_bind_host(callback_url);
// Port zero asks the OS for a free ephemeral port; the resolved
// redirect receives that port after the listener has been bound.
let bind_addr = match callback_port {
Some(port) => format!("{bind_host}:{port}"),
None => format!("{bind_host}:0"),
};
let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
let guard = CallbackServerGuard {
server: Arc::clone(&server),
};
let redirect_uri = resolve_redirect_uri(&server, callback_url)?;
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let PreparedOAuthLogin {
oauth_state,
authorization_server_issuer,
} = if let Some(oauth_client_id) =
oauth_client_id.filter(|client_id| !client_id.trim().is_empty())
redirect_uri,
} = if let Some((oauth_client_id, (auth_manager, metadata))) =
oauth_client_id.zip(registered_authorization)
{
let redirect_uri = if callback_url.is_some() && !use_legacy_fallback {
redirect_uri
} else {
append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?
};
start_authorization(
server_url,
oauth_http_client,
auth_manager,
metadata,
&scope_refs,
&redirect_uri,
&callback_id,
oauth_client_id,
)
.await?
@@ -582,6 +630,9 @@ impl OauthLoginFlow {
)
.await?
};
let callback_path = callback_path_from_redirect_uri(&redirect_uri)?;
let (tx, rx) = oneshot::channel();
spawn_callback_server(server, tx, callback_path);
let auth_url = append_query_param(
&oauth_state.get_authorization_url().await?,
"resource",
@@ -700,19 +751,28 @@ impl OauthLoginFlow {
}
}
async fn start_authorization(
async fn resolve_authorization_manager(
server_url: &str,
http_client: Arc<dyn OAuthHttpClient>,
scopes: &[&str],
redirect_uri: &str,
oauth_client_id: &str,
) -> Result<PreparedOAuthLogin> {
) -> Result<(AuthorizationManager, AuthorizationMetadata)> {
let mut auth_manager =
AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?;
auth_manager.set_allow_missing_issuer(true);
let metadata = auth_manager.resolve_metadata().await?.metadata;
validate_authorization_server_endpoints(&metadata)?;
Ok((auth_manager, metadata))
}
async fn start_authorization(
mut auth_manager: AuthorizationManager,
metadata: AuthorizationMetadata,
scopes: &[&str],
redirect_uri: &str,
callback_id: &str,
oauth_client_id: &str,
) -> Result<PreparedOAuthLogin> {
let authorization_server_issuer = metadata.issuer.clone();
validate_callback_redirect(redirect_uri, callback_id, callback_mode(&metadata)?)?;
auth_manager.set_metadata(metadata);
auth_manager.configure_client(
OAuthClientConfig::new(oauth_client_id, redirect_uri)
@@ -727,6 +787,7 @@ async fn start_authorization(
redirect_uri,
)),
authorization_server_issuer,
redirect_uri: redirect_uri.to_string(),
})
}
@@ -792,6 +853,7 @@ mod tests {
use super::parse_oauth_callback;
use super::perform_oauth_login;
use super::perform_oauth_login_silent;
use super::resolve_authorization_manager;
use super::start_authorization;
use crate::oauth::stored_oauth_credentials;
use crate::oauth::test_support::TempCodexHome;
@@ -942,6 +1004,7 @@ mod tests {
/*launch_browser*/ false,
/*callback_port*/ None,
/*callback_url*/ None,
/*global_callback_url*/ None,
Some(/*timeout_secs*/ 5),
)
.await?;
@@ -977,7 +1040,7 @@ mod tests {
for (scopes, expected_scope) in [(&[][..], None), (&["read"][..], Some("read"))] {
let (base_url, registration_requests) = spawn_oauth_metadata_server().await;
let redirect_uri = "http://127.0.0.1:43123/callback/configured-client";
let prepared = start_authorization(
let (auth_manager, metadata) = resolve_authorization_manager(
&format!("{base_url}/mcp"),
Arc::new(OAuthHttpClientAdapter::new(
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
@@ -986,8 +1049,15 @@ mod tests {
HeaderMap::new(),
&format!("{base_url}/mcp"),
)),
)
.await
.expect("resolve pre-registered OAuth metadata");
let prepared = start_authorization(
auth_manager,
metadata,
scopes,
redirect_uri,
"configured-client",
"eci-prd-pub-codex-123",
)
.await
@@ -1072,7 +1142,12 @@ mod tests {
.await
.expect("serve authorization metadata fixture");
});
let prepared = start_authorization(
let redirect_uri = if supports_issuer {
"http://127.0.0.1/callback"
} else {
"http://127.0.0.1/callback/test-callback"
};
let (auth_manager, metadata) = resolve_authorization_manager(
&format!("{issuer}/mcp"),
Arc::new(OAuthHttpClientAdapter::new(
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
@@ -1081,8 +1156,15 @@ mod tests {
HeaderMap::new(),
&format!("{issuer}/mcp"),
)),
)
.await
.expect("resolve issuer-aware authorization metadata");
let prepared = start_authorization(
auth_manager,
metadata,
&[],
"http://127.0.0.1/callback",
redirect_uri,
"test-callback",
"test-client",
)
.await
@@ -1145,6 +1227,7 @@ mod tests {
/*oauth_resource*/ None,
/*callback_port*/ None,
/*callback_url*/ None,
/*global_callback_url*/ None,
http_client.clone(),
)
.await
@@ -1169,6 +1252,7 @@ mod tests {
/*oauth_resource*/ None,
/*callback_port*/ None,
/*callback_url*/ None,
/*global_callback_url*/ None,
http_client.clone(),
StreamableHttpRedirectMode::Legacy,
)
@@ -1276,6 +1360,11 @@ mod tests {
.expect("redirect URI should parse");
assert_eq!(redirect_uri, "http://127.0.0.1:1234/callback/abc123");
assert_eq!(
append_callback_id_to_redirect_uri(&redirect_uri, "abc123")
.expect("resolved redirect URI should parse"),
redirect_uri
);
}
#[test]
@@ -1292,6 +1381,38 @@ mod tests {
);
}
#[test]
fn portless_loopback_callbacks_use_the_active_listener_port() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("start callback listener");
let listener_port = server
.server_addr()
.to_ip()
.expect("resolve callback listener address")
.port();
for path in ["/callback", "/callback/callback-id", "/custom/callback"] {
let callback = format!("http://127.0.0.1{path}");
assert_eq!(
super::resolve_redirect_uri(&server, Some(&callback))
.expect("insert active listener port"),
format!("http://127.0.0.1:{listener_port}{path}")
);
}
for callback in [
"http://localhost/callback",
"http://127.0.0.1:3080/callback",
"https://127.0.0.1/callback",
"https://devbox.example.com/callback",
] {
assert_eq!(
super::resolve_redirect_uri(&server, Some(callback))
.expect("preserve configured callback origin"),
callback
);
}
}
#[test]
fn append_query_param_adds_resource_to_absolute_url() {
let url = append_query_param(
@@ -7,6 +7,7 @@ use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::HttpClient;
use codex_rmcp_client::McpOAuthCallbackMode;
use codex_rmcp_client::McpOAuthClientRegistration;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::StreamableHttpOAuthDiscovery;
@@ -230,6 +231,7 @@ async fn assert_legacy_oauth_without_starting_an_mcp_session(
discovery?,
Some(StreamableHttpOAuthDiscovery {
scopes_supported: Some(vec!["mcp:read".to_string()]),
callback_mode: McpOAuthCallbackMode::CallbackSpecific,
}),
);
}
@@ -655,6 +657,7 @@ async fn interactive_oauth_rejects_untrusted_authorization_metadata() -> anyhow:
Some(/*timeout_secs*/ 5),
/*callback_port*/ None,
/*callback_url*/ None,
/*global_callback_url*/ None,
local_http_client(),
StreamableHttpRedirectMode::Legacy,
)