Skip to content

Commit 857c0dd

Browse files
committed
Serialize MCP OAuth login and logout
1 parent 5eaa4d4 commit 857c0dd

6 files changed

Lines changed: 307 additions & 6 deletions

File tree

codex-rs/cli/src/mcp_cmd.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use codex_mcp::oauth_login_support;
2929
use codex_mcp::resolve_oauth_scopes;
3030
use codex_mcp::should_retry_without_scopes;
3131
use codex_protocol::protocol::McpAuthStatus;
32-
use codex_rmcp_client::delete_oauth_tokens;
32+
use codex_rmcp_client::delete_oauth_tokens_locked;
3333
use codex_rmcp_client::perform_oauth_login;
3434
use codex_utils_cli::CliConfigOverrides;
3535
use codex_utils_cli::format_env_display;
@@ -523,12 +523,14 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
523523
_ => bail!("OAuth logout is only supported for streamable_http transports."),
524524
};
525525

526-
match delete_oauth_tokens(
526+
match delete_oauth_tokens_locked(
527527
&name,
528528
&url,
529529
config.mcp_oauth_credentials_store_mode,
530530
config.auth_keyring_backend_kind(),
531-
) {
531+
)
532+
.await
533+
{
532534
Ok(true) => println!("Removed OAuth credentials for '{name}'."),
533535
Ok(false) => println!("No OAuth credentials stored for '{name}'."),
534536
Err(err) => return Err(anyhow!("failed to delete OAuth credentials: {err}")),
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use std::path::Path;
2+
3+
use anyhow::Result;
4+
use codex_config::types::AuthKeyringBackendKind;
5+
use codex_config::types::OAuthCredentialsStoreMode;
6+
use codex_rmcp_client::StoredOAuthTokens;
7+
use codex_rmcp_client::save_oauth_tokens;
8+
use predicates::str::contains;
9+
use serde_json::json;
10+
use tempfile::TempDir;
11+
12+
const SERVER_NAME: &str = "oauth-server";
13+
const SERVER_URL: &str = "https://example.com/mcp";
14+
15+
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
16+
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
17+
cmd.env("CODEX_HOME", codex_home);
18+
Ok(cmd)
19+
}
20+
21+
#[tokio::test]
22+
async fn mcp_logout_cli_removes_file_credentials() -> Result<()> {
23+
let codex_home = TempDir::new()?;
24+
let status = tokio::process::Command::new(std::env::current_exe()?)
25+
.args([
26+
"mcp_logout_cli_child",
27+
"--exact",
28+
"--ignored",
29+
"--nocapture",
30+
])
31+
.env("CODEX_HOME", codex_home.path())
32+
.status()
33+
.await?;
34+
anyhow::ensure!(status.success(), "MCP logout child failed: {status}");
35+
Ok(())
36+
}
37+
38+
#[tokio::test]
39+
#[ignore = "spawned by mcp_logout_cli_removes_file_credentials"]
40+
async fn mcp_logout_cli_child() -> Result<()> {
41+
let codex_home = std::env::var("CODEX_HOME")?;
42+
let codex_home = Path::new(&codex_home);
43+
std::fs::write(
44+
codex_home.join("config.toml"),
45+
format!(
46+
"mcp_oauth_credentials_store = \"file\"\n\n[mcp_servers.{SERVER_NAME}]\nurl = \"{SERVER_URL}\"\n"
47+
),
48+
)?;
49+
50+
let tokens: StoredOAuthTokens = serde_json::from_value(json!({
51+
"server_name": SERVER_NAME,
52+
"url": SERVER_URL,
53+
"client_id": "test-client-id",
54+
"token_response": {
55+
"access_token": "access-token",
56+
"token_type": "bearer",
57+
"expires_in": 3600,
58+
"refresh_token": "refresh-token",
59+
},
60+
"expires_at": null,
61+
}))?;
62+
save_oauth_tokens(
63+
SERVER_NAME,
64+
&tokens,
65+
OAuthCredentialsStoreMode::File,
66+
AuthKeyringBackendKind::default(),
67+
)?;
68+
assert!(codex_home.join(".credentials.json").exists());
69+
70+
let mut logout = codex_command(codex_home)?;
71+
logout
72+
.args(["mcp", "logout", SERVER_NAME])
73+
.assert()
74+
.success()
75+
.stdout(contains(format!(
76+
"Removed OAuth credentials for '{SERVER_NAME}'."
77+
)));
78+
assert!(!codex_home.join(".credentials.json").exists());
79+
Ok(())
80+
}

codex-rs/rmcp-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub use in_process_transport::InProcessTransportFactory;
2727
pub use oauth::StoredOAuthTokens;
2828
pub use oauth::WrappedOAuthTokenResponse;
2929
pub use oauth::delete_oauth_tokens;
30+
pub use oauth::delete_oauth_tokens_locked;
3031
pub use oauth::save_oauth_tokens;
3132
pub use perform_oauth_login::OAuthProviderError;
3233
pub use perform_oauth_login::OauthLoginHandle;

codex-rs/rmcp-client/src/oauth.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ use codex_keyring_store::DefaultKeyringStore;
6464
use codex_keyring_store::KeyringStore;
6565
use codex_utils_home_dir::find_codex_home;
6666

67+
use self::refresh_lock::RefreshCredentialLock;
6768
pub(crate) use self::refresh_transaction::request_oauth_token_response;
6869
pub(crate) use self::resolved_store::ResolvedOAuthCredentialStore;
6970
pub(crate) use self::resolved_store::ResolvedOAuthTokens;
@@ -240,6 +241,25 @@ pub fn save_oauth_tokens(
240241
)
241242
}
242243

244+
pub(crate) async fn save_oauth_tokens_locked(
245+
server_name: &str,
246+
tokens: &StoredOAuthTokens,
247+
store_mode: OAuthCredentialsStoreMode,
248+
keyring_backend_kind: AuthKeyringBackendKind,
249+
) -> Result<()> {
250+
// Login persistence shares the refresh transaction lock so a completed login always becomes
251+
// authoritative: it either lands before refresh's reread or waits and overwrites the refresh
252+
// result afterward.
253+
let _lock = RefreshCredentialLock::acquire_for_server(server_name, &tokens.url).await?;
254+
save_oauth_tokens_with_keyring_store(
255+
&DefaultKeyringStore,
256+
server_name,
257+
tokens,
258+
store_mode,
259+
keyring_backend_kind,
260+
)
261+
}
262+
243263
fn save_oauth_tokens_with_keyring_store<K: KeyringStore + Clone + 'static>(
244264
keyring_store: &K,
245265
server_name: &str,
@@ -393,6 +413,24 @@ pub fn delete_oauth_tokens(
393413
)
394414
}
395415

416+
pub async fn delete_oauth_tokens_locked(
417+
server_name: &str,
418+
url: &str,
419+
store_mode: OAuthCredentialsStoreMode,
420+
keyring_backend_kind: AuthKeyringBackendKind,
421+
) -> Result<bool> {
422+
// Logout shares the refresh transaction lock so refresh cannot resurrect credentials after a
423+
// completed delete: it either observes the deletion or finishes before logout removes it.
424+
let _lock = RefreshCredentialLock::acquire_for_server(server_name, url).await?;
425+
delete_oauth_tokens_from_keyring_and_file(
426+
&DefaultKeyringStore,
427+
store_mode,
428+
keyring_backend_kind,
429+
server_name,
430+
url,
431+
)
432+
}
433+
396434
fn delete_oauth_tokens_from_keyring_and_file<K: KeyringStore + Clone + 'static>(
397435
keyring_store: &K,
398436
store_mode: OAuthCredentialsStoreMode,

codex-rs/rmcp-client/src/oauth/tests/persistor_tests.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@ use std::time::Duration;
55
use anyhow::Context;
66
use anyhow::Result;
77
use codex_config::types::AuthKeyringBackendKind;
8+
use codex_config::types::OAuthCredentialsStoreMode;
9+
use codex_exec_server::Environment;
810
use keyring::Error as KeyringError;
911
use oauth2::AccessToken;
1012
use oauth2::TokenResponse;
1113
use pretty_assertions::assert_eq;
1214
use rmcp::transport::auth::AuthorizationManager;
1315
use rmcp::transport::auth::OAuthState;
1416
use tokio::sync::Mutex as TokioMutex;
17+
use tracing::Event;
18+
use tracing::Id;
19+
use tracing::Metadata;
20+
use tracing::Subscriber;
21+
use tracing::span::Attributes;
22+
use tracing::span::Record;
23+
use tracing::subscriber::Interest;
1524
use wiremock::Mock;
1625
use wiremock::MockServer;
1726
use wiremock::ResponseTemplate;
@@ -27,6 +36,7 @@ use crate::oauth::ResolvedOAuthCredentialStore;
2736
use crate::oauth::StoredOAuthTokens;
2837
use crate::oauth::WrappedOAuthTokenResponse;
2938
use crate::oauth::compute_store_key;
39+
use crate::oauth::delete_oauth_tokens_locked;
3040
use crate::oauth::fallback_file_path;
3141
use crate::oauth::load_oauth_tokens_from_file;
3242
use crate::oauth::load_oauth_tokens_from_keyring;
@@ -35,6 +45,51 @@ use crate::oauth::refresh_transaction::RefreshReason;
3545
use crate::oauth::save_oauth_tokens_to_file;
3646
use crate::oauth::save_oauth_tokens_with_keyring;
3747

48+
const REFRESH_LOCK_CONTENTION_EVENT_TARGET: &str =
49+
"codex_rmcp_client::oauth::refresh_lock::contention";
50+
51+
struct LockContentionSubscriber {
52+
contended_tx: mpsc::Sender<()>,
53+
}
54+
55+
impl Subscriber for LockContentionSubscriber {
56+
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
57+
metadata.target() == REFRESH_LOCK_CONTENTION_EVENT_TARGET
58+
}
59+
60+
fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest {
61+
if self.enabled(metadata) {
62+
Interest::always()
63+
} else {
64+
Interest::never()
65+
}
66+
}
67+
68+
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
69+
Some(tracing::level_filters::LevelFilter::DEBUG)
70+
}
71+
72+
fn new_span(&self, _span: &Attributes<'_>) -> Id {
73+
Id::from_u64(/*u*/ 1)
74+
}
75+
76+
fn record(&self, _span: &Id, _values: &Record<'_>) {}
77+
78+
fn record_follows_from(&self, _span: &Id, _follows_from: &Id) {}
79+
80+
fn event(&self, event: &Event<'_>) {
81+
if self.enabled(event.metadata()) {
82+
self.contended_tx
83+
.send(())
84+
.expect("signal actual OAuth credential-lock contention");
85+
}
86+
}
87+
88+
fn enter(&self, _span: &Id) {}
89+
90+
fn exit(&self, _span: &Id) {}
91+
}
92+
3893
#[tokio::test(flavor = "current_thread")]
3994
async fn concurrent_refreshes_call_provider_once_and_carry_omitted_fields() -> Result<()> {
4095
let _env = TempCodexHome::new();
@@ -122,6 +177,130 @@ async fn delayed_unauthorized_retries_adopt_the_winning_token() -> Result<()> {
122177
Ok(())
123178
}
124179

180+
#[tokio::test(flavor = "current_thread")]
181+
async fn oauth_callback_waits_for_refresh_and_then_becomes_authoritative() -> Result<()> {
182+
let _env = TempCodexHome::new();
183+
let server = MockServer::start().await;
184+
mount_oauth_metadata(&server).await;
185+
Mock::given(method("POST"))
186+
.and(path("/oauth/token"))
187+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
188+
"access_token": "login-access-token",
189+
"token_type": "Bearer",
190+
"expires_in": 3600,
191+
"refresh_token": "login-refresh-token",
192+
"scope": "scope-a scope-b",
193+
})))
194+
.expect(1)
195+
.mount(&server)
196+
.await;
197+
198+
let server_name = "callback-lock-test";
199+
let server_url = format!("{}/mcp", server.uri());
200+
let held_lock = RefreshCredentialLock::acquire_for_server(server_name, &server_url).await?;
201+
let (contended_tx, contended_rx) = mpsc::channel();
202+
let _subscriber_guard =
203+
tracing::subscriber::set_default(LockContentionSubscriber { contended_tx });
204+
let handle = crate::perform_oauth_login_return_url_with_http_client(
205+
server_name,
206+
&server_url,
207+
OAuthCredentialsStoreMode::File,
208+
AuthKeyringBackendKind::default(),
209+
/*http_headers*/ None,
210+
/*env_http_headers*/ None,
211+
&["scope-a".to_string(), "scope-b".to_string()],
212+
Some("test-client-id"),
213+
/*oauth_resource*/ None,
214+
Some(/*timeout_secs*/ 5),
215+
/*callback_port*/ None,
216+
/*callback_url*/ None,
217+
Environment::default_for_tests().get_http_client(),
218+
)
219+
.await?;
220+
let authorization_url = reqwest::Url::parse(handle.authorization_url())?;
221+
let query = authorization_url
222+
.query_pairs()
223+
.collect::<std::collections::HashMap<_, _>>();
224+
let redirect_uri = query
225+
.get("redirect_uri")
226+
.context("authorization URL omitted redirect_uri")?;
227+
let state = query
228+
.get("state")
229+
.context("authorization URL omitted state")?;
230+
let mut callback_url = reqwest::Url::parse(redirect_uri)?;
231+
callback_url
232+
.query_pairs_mut()
233+
.append_pair("code", "authorization-code")
234+
.append_pair("state", state);
235+
let (_authorization_url, completion) = handle.into_parts();
236+
reqwest::Client::new()
237+
.get(callback_url)
238+
.send()
239+
.await?
240+
.error_for_status()?;
241+
242+
// This event is emitted only after the callback's real persistence path observes WouldBlock.
243+
// Writing while the lock is held models a refresh that started first; login must overwrite it
244+
// after the transaction finishes.
245+
wait_for_signal(contended_rx).await?;
246+
let mut refresh_winner = sample_tokens();
247+
refresh_winner.server_name = server_name.to_string();
248+
refresh_winner.url.clone_from(&server_url);
249+
refresh_winner
250+
.token_response
251+
.0
252+
.set_access_token(AccessToken::new("refresh-winner".to_string()));
253+
save_oauth_tokens_to_file(&refresh_winner)?;
254+
drop(held_lock);
255+
completion
256+
.await
257+
.context("OAuth login task was cancelled")??;
258+
259+
let stored = load_oauth_tokens_from_file(server_name, &server_url)?
260+
.expect("callback credentials should be persisted");
261+
assert_eq!(
262+
stored.token_response.0.access_token().secret(),
263+
"login-access-token"
264+
);
265+
server.verify().await;
266+
Ok(())
267+
}
268+
269+
#[tokio::test(flavor = "current_thread")]
270+
async fn locked_logout_waits_for_refresh_and_removes_its_result() -> Result<()> {
271+
let _env = TempCodexHome::new();
272+
let server = MockServer::start().await;
273+
mount_oauth_metadata(&server).await;
274+
let refresh_started = mount_delayed_refresh(&server, "refreshed-before-logout").await;
275+
let initial = expired_tokens(&format!("{}/mcp", server.uri()));
276+
save_oauth_tokens_to_file(&initial)?;
277+
let persistor = persistor_for(&initial).await?;
278+
let refresh_task = tokio::spawn(async move { persistor.refresh_if_needed().await });
279+
wait_for_signal(refresh_started).await?;
280+
281+
let (contended_tx, contended_rx) = mpsc::channel();
282+
let _subscriber_guard =
283+
tracing::subscriber::set_default(LockContentionSubscriber { contended_tx });
284+
let server_name = initial.server_name.clone();
285+
let url = initial.url.clone();
286+
let logout_task = tokio::spawn(async move {
287+
delete_oauth_tokens_locked(
288+
&server_name,
289+
&url,
290+
OAuthCredentialsStoreMode::File,
291+
AuthKeyringBackendKind::default(),
292+
)
293+
.await
294+
});
295+
296+
wait_for_signal(contended_rx).await?;
297+
refresh_task.await??;
298+
assert!(logout_task.await??);
299+
assert!(load_oauth_tokens_from_file(&initial.server_name, &initial.url)?.is_none());
300+
server.verify().await;
301+
Ok(())
302+
}
303+
125304
#[tokio::test(flavor = "current_thread")]
126305
async fn resolved_keyring_read_error_preserves_in_memory_credentials() -> Result<()> {
127306
let _env = TempCodexHome::new();

0 commit comments

Comments
 (0)