Skip to content

Commit 6cf42cf

Browse files
Serialize shared MCP OAuth credential stores (#30292)
[Codex Thread 019edd6d-6f14-74e2-853c-345d1803d4a6](https://codex-thread-link.openai.chatgpt-team.site/thread/019edd6d-6f14-74e2-853c-345d1803d4a6) ## Stack Review and merge in order. Every layer is independently correct and documents its safe stopping point. 1. [#30292](#30292) — aggregate File/Secrets store locking 2. [#30293](#30293) — resolve and lifecycle-pin the exact OAuth store 3. [#30416](#30416) — serialized authoritative refresh transaction 4. [#30294](#30294) — Codex-owned transport refresh and one-shot 401 recovery 5. [#30295](#30295) — login/logout transaction serialization 6. [#30296](#30296) — diagnostic-only Auto store drift reporting **This PR is layer 1.** ## Why MCP OAuth credentials stored in File or Secrets share one aggregate map. Concurrent read-modify-write operations for different MCP servers can both read the same snapshot and let the later write discard the earlier update. That is a correctness problem independent of refresh-token rotation. ## What this PR does - Adds a bounded cross-process lock around aggregate File and Secrets loads, saves, and deletes. - Distinguishes aggregate-lock failures from Secrets backend unavailability, so Auto can fall back only for the latter and cannot bypass serialization by reading or writing File. - Keeps Direct keyring operations outside this lock because they are already per credential. - Releases the Secrets aggregate lock before legacy File cleanup so cross-store cleanup cannot create nested aggregate-lock ordering. - Tests actual contention by waiting for an observed `WouldBlock`, rather than assuming a sleeping worker reached the lock. - Tests load and save with only the Secrets lock path broken while fallback File remains readable and writable. ## Decisions and non-goals - This lock protects aggregate-store read-modify-write integrity only. It does not choose a credential authority or serialize an OAuth refresh transaction. - The lock is scoped to the active `CODEX_HOME`, matching the aggregate files it protects. - Lock waits are bounded, and coordination failures are surfaced rather than treated as evidence that Secrets is unavailable. ## Safe stopping point This PR can merge alone. It prevents lost updates and partial aggregate reads. Auto can still resolve again during a client lifecycle until layer 2, and concurrent refreshes remain possible until layer 3. ## Validation - `just test -p codex-rmcp-client` (96 passed; expected environment skips) - Focused aggregate File/Secrets lock contention and Auto fallback tests
1 parent 6afcf26 commit 6cf42cf

4 files changed

Lines changed: 775 additions & 55 deletions

File tree

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

Lines changed: 68 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
//!
1717
//! If the keyring is not available or fails, we fall back to CODEX_HOME/.credentials.json which is consistent with other coding CLI agents.
1818
19+
mod store_lock;
20+
21+
#[cfg(test)]
22+
#[path = "oauth/test_support.rs"]
23+
mod test_support;
24+
1925
use anyhow::Context;
2026
use anyhow::Error;
2127
use anyhow::Result;
@@ -49,6 +55,10 @@ use std::time::SystemTime;
4955
use std::time::UNIX_EPOCH;
5056
use tracing::warn;
5157

58+
use self::store_lock::OAuthStore;
59+
use self::store_lock::OAuthStoreLock;
60+
use self::store_lock::OAuthStoreLockFailure;
61+
5262
use codex_keyring_store::DefaultKeyringStore;
5363
use codex_keyring_store::KeyringStore;
5464
use rmcp::transport::auth::AuthorizationManager;
@@ -173,6 +183,11 @@ fn load_oauth_tokens_from_keyring_with_fallback_to_file<K: KeyringStore + Clone
173183
match load_oauth_tokens_from_keyring(keyring_store, keyring_backend_kind, server_name, url) {
174184
Ok(Some(tokens)) => Ok(Some(tokens)),
175185
Ok(None) => load_oauth_tokens_from_file(server_name, url),
186+
// A store lock failure means the configured aggregate authority could be changing, or
187+
// that coordination itself is unavailable. It is not evidence that the keyring backend
188+
// is unavailable, so consulting File here could replay credentials hidden behind a
189+
// newer Secrets entry. This is the load-side counterpart of the save guard below.
190+
Err(error) if error.downcast_ref::<OAuthStoreLockFailure>().is_some() => Err(error),
176191
Err(error) => {
177192
warn!("failed to read OAuth tokens from keyring: {error}");
178193
load_oauth_tokens_from_file(server_name, url)
@@ -220,6 +235,7 @@ fn load_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
220235
server_name: &str,
221236
url: &str,
222237
) -> Result<Option<StoredOAuthTokens>> {
238+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
223239
let codex_home = find_codex_home()?;
224240
let manager = SecretsManager::new_with_keyring_store_and_namespace(
225241
codex_home.to_path_buf(),
@@ -308,12 +324,39 @@ fn save_oauth_tokens_to_direct_keyring<K: KeyringStore>(
308324
}
309325
}
310326

327+
/// Saves one credential while holding the Secrets aggregate-store lock across the mutation.
328+
///
329+
/// The Secrets lock is released before fallback File cleanup to preserve aggregate-lock ordering.
311330
fn save_oauth_tokens_to_secrets_keyring<K: KeyringStore + Clone + 'static>(
312331
keyring_store: &K,
313332
server_name: &str,
314333
tokens: &StoredOAuthTokens,
315334
) -> Result<()> {
316335
let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?;
336+
{
337+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
338+
save_oauth_tokens_to_secrets_keyring_with_lock_held(
339+
keyring_store,
340+
server_name,
341+
tokens,
342+
&serialized,
343+
)?;
344+
}
345+
346+
let key = compute_store_key(server_name, &tokens.url)?;
347+
if let Err(error) = delete_oauth_tokens_from_file(&key) {
348+
warn!("failed to remove OAuth tokens from fallback storage: {error:?}");
349+
}
350+
Ok(())
351+
}
352+
353+
/// Writes one credential to Secrets. The caller must hold the Secrets aggregate-store lock.
354+
fn save_oauth_tokens_to_secrets_keyring_with_lock_held<K: KeyringStore + Clone + 'static>(
355+
keyring_store: &K,
356+
server_name: &str,
357+
tokens: &StoredOAuthTokens,
358+
serialized: &str,
359+
) -> Result<()> {
317360
let codex_home = find_codex_home()?;
318361
let manager = SecretsManager::new_with_keyring_store_and_namespace(
319362
codex_home.to_path_buf(),
@@ -323,14 +366,8 @@ fn save_oauth_tokens_to_secrets_keyring<K: KeyringStore + Clone + 'static>(
323366
);
324367
let secret_name = compute_secret_name(server_name, &tokens.url)?;
325368
manager
326-
.set(&SecretScope::Global, &secret_name, &serialized)
327-
.context("failed to write OAuth tokens to encrypted storage")?;
328-
329-
let key = compute_store_key(server_name, &tokens.url)?;
330-
if let Err(error) = delete_oauth_tokens_from_file(&key) {
331-
warn!("failed to remove OAuth tokens from fallback storage: {error:?}");
332-
}
333-
Ok(())
369+
.set(&SecretScope::Global, &secret_name, serialized)
370+
.context("failed to write OAuth tokens to encrypted storage")
334371
}
335372

336373
fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone + 'static>(
@@ -341,6 +378,10 @@ fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone
341378
) -> Result<()> {
342379
match save_oauth_tokens_with_keyring(keyring_store, keyring_backend_kind, server_name, tokens) {
343380
Ok(()) => Ok(()),
381+
// As on load, a store lock failure is a coordination failure rather than evidence that
382+
// the keyring backend is unavailable. Falling back could leave a newer File token hidden
383+
// behind a stale Secrets entry.
384+
Err(error) if error.downcast_ref::<OAuthStoreLockFailure>().is_some() => Err(error),
344385
Err(error) => {
345386
let message = error.to_string();
346387
warn!("falling back to file storage for OAuth tokens: {message}");
@@ -430,6 +471,7 @@ fn delete_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
430471
server_name: &str,
431472
url: &str,
432473
) -> Result<bool> {
474+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
433475
let codex_home = find_codex_home()?;
434476
let manager = SecretsManager::new_with_keyring_store_and_namespace(
435477
codex_home.to_path_buf(),
@@ -592,7 +634,8 @@ struct FallbackTokenEntry {
592634
}
593635

594636
fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result<Option<StoredOAuthTokens>> {
595-
let Some(store) = read_fallback_file()? else {
637+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
638+
let Some(store) = read_fallback_file_unlocked()? else {
596639
return Ok(None);
597640
};
598641

@@ -634,9 +677,17 @@ fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result<Option<St
634677
Ok(None)
635678
}
636679

680+
/// Saves one credential while holding the File aggregate-store lock across the full
681+
/// read-modify-write operation.
637682
fn save_oauth_tokens_to_file(tokens: &StoredOAuthTokens) -> Result<()> {
683+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
684+
save_oauth_tokens_to_file_with_lock_held(tokens)
685+
}
686+
687+
/// Updates the fallback File. The caller must hold the File aggregate-store lock.
688+
fn save_oauth_tokens_to_file_with_lock_held(tokens: &StoredOAuthTokens) -> Result<()> {
638689
let key = compute_store_key(&tokens.server_name, &tokens.url)?;
639-
let mut store = read_fallback_file()?.unwrap_or_default();
690+
let mut store = read_fallback_file_unlocked()?.unwrap_or_default();
640691

641692
let token_response = &tokens.token_response.0;
642693
let expires_at = tokens
@@ -664,7 +715,8 @@ fn save_oauth_tokens_to_file(tokens: &StoredOAuthTokens) -> Result<()> {
664715
}
665716

666717
fn delete_oauth_tokens_from_file(key: &str) -> Result<bool> {
667-
let mut store = match read_fallback_file()? {
718+
let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
719+
let mut store = match read_fallback_file_unlocked()? {
668720
Some(store) => store,
669721
None => return Ok(false),
670722
};
@@ -750,7 +802,7 @@ fn fallback_file_path() -> Result<PathBuf> {
750802
Ok(find_codex_home()?.join(FALLBACK_FILENAME).to_path_buf())
751803
}
752804

753-
fn read_fallback_file() -> Result<Option<FallbackFile>> {
805+
fn read_fallback_file_unlocked() -> Result<Option<FallbackFile>> {
754806
let path = fallback_file_path()?;
755807
let contents = match fs::read_to_string(&path) {
756808
Ok(contents) => contents,
@@ -814,52 +866,13 @@ fn sha_256_prefix(value: &Value) -> Result<String> {
814866
mod tests {
815867
use super::*;
816868
use anyhow::Result;
869+
use codex_keyring_store::tests::MockKeyringStore;
817870
use codex_secrets::compute_keyring_account;
818871
use keyring::Error as KeyringError;
819872
use pretty_assertions::assert_eq;
820873
use std::sync::Arc;
821-
use std::sync::Mutex;
822-
use std::sync::MutexGuard;
823-
use std::sync::OnceLock;
824-
use std::sync::PoisonError;
825-
use tempfile::tempdir;
826-
827-
use codex_keyring_store::tests::MockKeyringStore;
828-
829-
struct TempCodexHome {
830-
_guard: MutexGuard<'static, ()>,
831-
_dir: tempfile::TempDir,
832-
}
833-
834-
impl TempCodexHome {
835-
fn new() -> Self {
836-
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
837-
let guard = LOCK
838-
.get_or_init(Mutex::default)
839-
.lock()
840-
.unwrap_or_else(PoisonError::into_inner);
841-
let dir = tempdir().expect("create CODEX_HOME temp dir");
842-
unsafe {
843-
std::env::set_var("CODEX_HOME", dir.path());
844-
}
845-
Self {
846-
_guard: guard,
847-
_dir: dir,
848-
}
849-
}
850874

851-
fn path(&self) -> &std::path::Path {
852-
self._dir.path()
853-
}
854-
}
855-
856-
impl Drop for TempCodexHome {
857-
fn drop(&mut self) {
858-
unsafe {
859-
std::env::remove_var("CODEX_HOME");
860-
}
861-
}
862-
}
875+
use super::test_support::TempCodexHome;
863876

864877
#[test]
865878
fn load_oauth_tokens_reads_from_keyring_when_available() -> Result<()> {
@@ -964,7 +977,7 @@ mod tests {
964977

965978
let fallback_path = super::fallback_file_path()?;
966979
assert!(fallback_path.exists(), "fallback file should be created");
967-
let saved = super::read_fallback_file()?.expect("fallback file should load");
980+
let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
968981
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
969982
let entry = saved.get(&key).expect("entry for key");
970983
assert_eq!(entry.server_name, tokens.server_name);
@@ -1076,7 +1089,7 @@ mod tests {
10761089
&tokens,
10771090
)?;
10781091

1079-
let saved = super::read_fallback_file()?.expect("fallback file should load");
1092+
let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
10801093
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
10811094
assert!(saved.contains_key(&key));
10821095
Ok(())

0 commit comments

Comments
 (0)