mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Add Codex-owned MCP OAuth recovery
This commit is contained in:
+326
-417
@@ -61,8 +61,6 @@ use codex_keyring_store::KeyringStore;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
|
||||
pub(crate) use self::persistor::OAuthPersistor;
|
||||
#[cfg(test)]
|
||||
use self::persistor::install_tokens_in_manager;
|
||||
use self::refresh_lock::RefreshCredentialLock;
|
||||
pub(crate) use self::resolved_store::LoadedOAuthTokens;
|
||||
pub(crate) use self::resolved_store::ResolvedOAuthCredentialStore;
|
||||
@@ -77,7 +75,8 @@ use rmcp::transport::auth::AuthorizationManager;
|
||||
|
||||
const KEYRING_SERVICE: &str = "Codex MCP Credentials";
|
||||
const MCP_OAUTH_SECRET_PREFIX: &str = "MCP_OAUTH";
|
||||
const REFRESH_SKEW_MILLIS: u64 = 30_000;
|
||||
// Refresh proactively so ordinary requests do not race token expiry.
|
||||
const REFRESH_SKEW_MILLIS: u64 = 60_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StoredOAuthTokens {
|
||||
@@ -1200,15 +1199,7 @@ mod tests {
|
||||
-> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/oauth-authorization-server/mcp"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"authorization_endpoint": format!("{}/oauth/authorize", server.uri()),
|
||||
"token_endpoint": format!("{}/oauth/token", server.uri()),
|
||||
"scopes_supported": ["scope-a", "scope-b"],
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
let key = super::compute_store_key(&initial_tokens.server_name, &initial_tokens.url)?;
|
||||
@@ -1244,29 +1235,43 @@ mod tests {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"updated-access-token",
|
||||
"updated-refresh-token",
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
let store = MockKeyringStore::default();
|
||||
let mut initial_tokens = sample_tokens();
|
||||
initial_tokens.url = format!("{}/mcp", server.uri());
|
||||
let mut updated_tokens = initial_tokens.clone();
|
||||
updated_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("updated-access-token".to_string()));
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&updated_tokens).await?;
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager.clone(),
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let key = super::compute_store_key(&updated_tokens.server_name, &updated_tokens.url)?;
|
||||
store.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move { persistor.refresh_if_needed_with_keyring_store(&store).await }
|
||||
});
|
||||
|
||||
let error = persistor
|
||||
.persist_if_needed_with_keyring_store(&store)
|
||||
.await
|
||||
wait_for_signal(refresh_started).await?;
|
||||
let key = super::compute_store_key(&initial_tokens.server_name, &initial_tokens.url)?;
|
||||
store.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
|
||||
let error = refresh_task
|
||||
.await?
|
||||
.expect_err("resolved keyring write should fail instead of falling back");
|
||||
|
||||
assert!(
|
||||
@@ -1276,139 +1281,107 @@ mod tests {
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
assert!(!super::fallback_file_path()?.exists());
|
||||
let manager_tokens = tokens_from_manager(&manager).await?;
|
||||
assert_eq!(access_token(&manager_tokens), "updated-access-token");
|
||||
assert_eq!(
|
||||
refresh_token(&manager_tokens),
|
||||
Some("updated-refresh-token".to_string())
|
||||
);
|
||||
|
||||
// B remains authoritative in memory without retrying the failed durable write.
|
||||
persistor
|
||||
.persist_if_needed_with_keyring_store(&store)
|
||||
.await?;
|
||||
// B is now the in-process authority even though its save failed. A later operation may use
|
||||
// B while it remains healthy, but we deliberately do not retry persistence here. If B
|
||||
// later needs refresh, the persistor refuses to reread and replay durable A.
|
||||
persistor
|
||||
.refresh_if_needed_with_keyring_store(&store)
|
||||
.await?;
|
||||
let manager_tokens = tokens_from_manager(&manager).await?;
|
||||
assert_eq!(access_token(&manager_tokens), "updated-access-token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unpersisted_refresh_does_not_reinstall_unchanged_durable_credentials() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let failing_store = MockKeyringStore::default();
|
||||
let mut initial_tokens = sample_tokens();
|
||||
initial_tokens.url = format!("{}/mcp", server.uri());
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&failing_store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let mut unpersisted_tokens = initial_tokens.clone();
|
||||
unpersisted_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("unpersisted-access-token".to_string()));
|
||||
let manager = authorization_manager_for(&unpersisted_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager.clone(),
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let key = super::compute_store_key(&initial_tokens.server_name, &initial_tokens.url)?;
|
||||
failing_store.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
|
||||
persistor
|
||||
.persist_if_needed_with_keyring_store(&failing_store)
|
||||
.await
|
||||
.expect_err("the refreshed credential should remain unpersisted");
|
||||
|
||||
let unchanged_store = MockKeyringStore::default();
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&unchanged_store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
let mut subsequent_tokens = unpersisted_tokens.clone();
|
||||
subsequent_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("subsequent-access-token".to_string()));
|
||||
install_tokens_in_manager(&manager, &subsequent_tokens).await?;
|
||||
|
||||
persistor
|
||||
.persist_if_needed_locked_with_keyring_store(&unchanged_store)
|
||||
.refresh_after_unauthorized_with_keyring_store(
|
||||
&store,
|
||||
initial_tokens.token_response.0.access_token().clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let manager_tokens = tokens_from_manager(&manager).await?;
|
||||
assert_eq!(
|
||||
manager_tokens.token_response,
|
||||
subsequent_tokens.token_response
|
||||
let error = persistor
|
||||
.refresh_after_unauthorized_with_keyring_store(
|
||||
&store,
|
||||
AccessToken::new("updated-access-token".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect_err("B cannot be refreshed by rereading durable A");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("previous refresh succeeded but its credentials were not persisted"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
let stored = super::load_oauth_tokens_from_keyring(
|
||||
&unchanged_store,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
&subsequent_tokens.server_name,
|
||||
&subsequent_tokens.url,
|
||||
)?
|
||||
.expect("the subsequent credentials should replace the unchanged stale snapshot");
|
||||
assert_eq!(stored.token_response, subsequent_tokens.token_response);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_secrets_write_does_not_cleanup_fallback_file() -> Result<()> {
|
||||
async fn unauthorized_transaction_without_expiry_refreshes_once_across_clients() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
mount_refresh_response(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"refreshed-access-token",
|
||||
"rotated-refresh-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let mut initial_tokens = sample_tokens();
|
||||
initial_tokens.url = format!("{}/mcp", server.uri());
|
||||
super::save_oauth_tokens_to_file(&initial_tokens)?;
|
||||
let fallback_path = super::fallback_file_path()?;
|
||||
let fallback_before = fs::read(&fallback_path)?;
|
||||
initial_tokens.expires_at = None;
|
||||
initial_tokens.token_response.0.set_expires_in(None);
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let mut updated_tokens = initial_tokens.clone();
|
||||
updated_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("updated-access-token".to_string()));
|
||||
let manager = authorization_manager_for(&updated_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
let first_manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let second_manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let first = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Secrets),
|
||||
first_manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let second = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
second_manager.clone(),
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
|
||||
persistor
|
||||
.persist_if_needed_with_keyring_store(&store)
|
||||
first
|
||||
.refresh_after_unauthorized_with_keyring_store(&store)
|
||||
.await?;
|
||||
second
|
||||
.refresh_after_unauthorized_with_keyring_store(&store)
|
||||
.await?;
|
||||
|
||||
assert_eq!(fs::read(&fallback_path)?, fallback_before);
|
||||
let secrets_tokens = super::load_oauth_tokens_from_keyring(
|
||||
server.verify().await;
|
||||
let stored = super::load_oauth_tokens_from_keyring(
|
||||
&store,
|
||||
AuthKeyringBackendKind::Secrets,
|
||||
&updated_tokens.server_name,
|
||||
&updated_tokens.url,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
)?
|
||||
.expect("updated tokens should be persisted to resolved Secrets storage");
|
||||
let mut expected_tokens = updated_tokens;
|
||||
expected_tokens.expires_at = secrets_tokens.expires_at;
|
||||
assert_tokens_match_without_expiry(&secrets_tokens, &expected_tokens);
|
||||
.expect("refreshed tokens should be persisted");
|
||||
assert_eq!(access_token(&stored), "refreshed-access-token");
|
||||
assert_eq!(
|
||||
refresh_token(&stored),
|
||||
Some("rotated-refresh-token".to_string())
|
||||
);
|
||||
let second_tokens = tokens_from_manager(&second_manager).await?;
|
||||
assert_eq!(second_tokens.token_response, stored.token_response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1461,19 +1434,15 @@ mod tests {
|
||||
.await?;
|
||||
|
||||
let manager_tokens = tokens_from_manager(&manager).await?;
|
||||
assert_eq!(
|
||||
access_token(&manager_tokens),
|
||||
"already-refreshed-access-token"
|
||||
);
|
||||
assert_eq!(
|
||||
refresh_token(&manager_tokens),
|
||||
Some("already-rotated-refresh-token".to_string())
|
||||
assert_token_response_match_without_expiry(
|
||||
&manager_tokens.token_response,
|
||||
&latest_tokens.token_response,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_transaction_refreshes_when_only_derived_expires_in_drifted() -> Result<()> {
|
||||
async fn refresh_transaction_refreshes_in_guard_band_despite_expires_in_drift() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
@@ -1488,7 +1457,7 @@ mod tests {
|
||||
let store = MockKeyringStore::default();
|
||||
let mut initial_tokens = sample_tokens();
|
||||
initial_tokens.url = format!("{}/mcp", server.uri());
|
||||
initial_tokens.expires_at = Some(now_millis().saturating_add(5_000));
|
||||
initial_tokens.expires_at = Some(now_millis().saturating_add(45_000));
|
||||
initial_tokens
|
||||
.token_response
|
||||
.0
|
||||
@@ -1598,232 +1567,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_login_prevents_delayed_post_operation_overwrite() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
let store = MockKeyringStore::default();
|
||||
let (initial_tokens, manager, persistor) =
|
||||
persistor_after_rmcp_refresh(&store, &server).await?;
|
||||
|
||||
let login_tokens = tokens_with_credentials(
|
||||
initial_tokens.clone(),
|
||||
"login-access-token",
|
||||
"login-refresh-token",
|
||||
);
|
||||
super::save_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
&login_tokens.server_name,
|
||||
&login_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)
|
||||
.await?;
|
||||
|
||||
persistor
|
||||
.persist_if_needed_locked_with_keyring_store(&store)
|
||||
.await?;
|
||||
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?
|
||||
.expect("login tokens should remain persisted");
|
||||
assert_eq!(access_token(&stored.tokens), "login-access-token");
|
||||
assert_eq!(
|
||||
refresh_token(&stored.tokens),
|
||||
Some("login-refresh-token".to_string())
|
||||
);
|
||||
let manager_tokens = tokens_from_manager(&manager).await?;
|
||||
assert_eq!(access_token(&manager_tokens), "login-access-token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_login_save_after_refresh_still_wins() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"refreshed-before-login",
|
||||
"rotated-before-login",
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move { persistor.refresh_if_needed_with_keyring_store(&store).await }
|
||||
});
|
||||
|
||||
wait_for_signal(refresh_started).await?;
|
||||
|
||||
let mut login_tokens = sample_tokens();
|
||||
login_tokens.url.clone_from(&initial_tokens.url);
|
||||
login_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("login-after-refresh-access".to_string()));
|
||||
login_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_refresh_token(Some(RefreshToken::new(
|
||||
"login-after-refresh-token".to_string(),
|
||||
)));
|
||||
super::save_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
&login_tokens.server_name,
|
||||
&login_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)
|
||||
.await?;
|
||||
refresh_task.await??;
|
||||
|
||||
server.verify().await;
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?
|
||||
.expect("login tokens should remain persisted");
|
||||
assert_eq!(access_token(&stored.tokens), "login-after-refresh-access");
|
||||
assert_eq!(
|
||||
refresh_token(&stored.tokens),
|
||||
Some("login-after-refresh-token".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "AuthorizationManager async access must be serialized through its mutex"
|
||||
)]
|
||||
async fn completed_logout_prevents_delayed_post_operation_resurrection() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
let store = MockKeyringStore::default();
|
||||
let (initial_tokens, manager, persistor) =
|
||||
persistor_after_rmcp_refresh(&store, &server).await?;
|
||||
|
||||
let removed = super::delete_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
)
|
||||
.await?;
|
||||
assert!(removed);
|
||||
|
||||
persistor
|
||||
.persist_if_needed_locked_with_keyring_store(&store)
|
||||
.await?;
|
||||
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
assert!(stored.is_none());
|
||||
let guard = manager.lock().await;
|
||||
let (_, manager_tokens) = guard.get_credentials().await?;
|
||||
assert!(manager_tokens.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_logout_after_refresh_still_deletes() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"refreshed-before-logout",
|
||||
"rotated-before-logout",
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move { persistor.refresh_if_needed_with_keyring_store(&store).await }
|
||||
});
|
||||
|
||||
wait_for_signal(refresh_started).await?;
|
||||
|
||||
let removed = super::delete_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
)
|
||||
.await?;
|
||||
refresh_task.await??;
|
||||
|
||||
server.verify().await;
|
||||
assert!(removed);
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
assert!(stored.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_refresh_timeout_permits_a_later_serialized_retry() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
@@ -1970,6 +1713,223 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_refresh_timeout_releases_lock_without_persisting() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"late-access-token",
|
||||
"late-refresh-token",
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move {
|
||||
persistor
|
||||
.refresh_if_needed_with_keyring_store_and_timeout(
|
||||
&store,
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
wait_for_signal(refresh_started).await?;
|
||||
let error = refresh_task
|
||||
.await?
|
||||
.expect_err("delayed provider response should time out");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"timed out after 200ms refreshing OAuth tokens for server test-server"
|
||||
);
|
||||
|
||||
let store_key = super::compute_store_key(&initial_tokens.server_name, &initial_tokens.url)?;
|
||||
let _lock =
|
||||
RefreshCredentialLock::acquire_with_timeout(&store_key, Duration::from_millis(100))
|
||||
.await?;
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?
|
||||
.expect("original tokens should remain persisted");
|
||||
assert_eq!(access_token(&stored.tokens), "access-token");
|
||||
assert_eq!(
|
||||
refresh_token(&stored.tokens),
|
||||
Some("refresh-token".to_string())
|
||||
);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_login_save_after_refresh_still_wins() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"refreshed-before-login",
|
||||
"rotated-before-login",
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move { persistor.refresh_if_needed_with_keyring_store(&store).await }
|
||||
});
|
||||
|
||||
wait_for_signal(refresh_started).await?;
|
||||
|
||||
let mut login_tokens = sample_tokens();
|
||||
login_tokens.url.clone_from(&initial_tokens.url);
|
||||
login_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new("login-after-refresh-access".to_string()));
|
||||
login_tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_refresh_token(Some(RefreshToken::new(
|
||||
"login-after-refresh-token".to_string(),
|
||||
)));
|
||||
super::save_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
&login_tokens.server_name,
|
||||
&login_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)
|
||||
.await?;
|
||||
refresh_task.await??;
|
||||
|
||||
server.verify().await;
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?
|
||||
.expect("login tokens should remain persisted");
|
||||
assert_eq!(access_token(&stored.tokens), "login-after-refresh-access");
|
||||
assert_eq!(
|
||||
refresh_token(&stored.tokens),
|
||||
Some("login-after-refresh-token".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_logout_after_refresh_still_deletes() -> Result<()> {
|
||||
let _env = TempCodexHome::new();
|
||||
let server = MockServer::start().await;
|
||||
mount_oauth_metadata(&server).await;
|
||||
let refresh_started = mount_refresh_response_with_signal(
|
||||
&server,
|
||||
"refresh-token",
|
||||
"refreshed-before-logout",
|
||||
"rotated-before-logout",
|
||||
Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
|
||||
let store = MockKeyringStore::default();
|
||||
let initial_tokens = expired_sample_tokens(&format!("{}/mcp", server.uri()));
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager,
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let refresh_task = tokio::spawn({
|
||||
let persistor = persistor.clone();
|
||||
let store = store.clone();
|
||||
async move { persistor.refresh_if_needed_with_keyring_store(&store).await }
|
||||
});
|
||||
|
||||
wait_for_signal(refresh_started).await?;
|
||||
|
||||
let removed = super::delete_oauth_tokens_locked_with_keyring_store(
|
||||
&store,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
)
|
||||
.await?;
|
||||
refresh_task.await??;
|
||||
|
||||
server.verify().await;
|
||||
assert!(removed);
|
||||
let stored = super::load_oauth_tokens_with_source_and_keyring_store(
|
||||
&store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens.url,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
assert!(stored.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_oauth_tokens_with_secrets_backend_falls_back_to_file_when_keyring_fails() -> Result<()>
|
||||
{
|
||||
@@ -2261,57 +2221,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn persistor_after_rmcp_refresh(
|
||||
store: &MockKeyringStore,
|
||||
server: &MockServer,
|
||||
) -> Result<(
|
||||
StoredOAuthTokens,
|
||||
Arc<TokioMutex<AuthorizationManager>>,
|
||||
OAuthPersistor,
|
||||
)> {
|
||||
mount_oauth_metadata(server).await;
|
||||
let mut initial_tokens = sample_tokens();
|
||||
initial_tokens.url = format!("{}/mcp", server.uri());
|
||||
super::save_oauth_tokens_with_keyring_store(
|
||||
store,
|
||||
&initial_tokens.server_name,
|
||||
&initial_tokens,
|
||||
OAuthCredentialsStoreMode::Keyring,
|
||||
AuthKeyringBackendKind::Direct,
|
||||
)?;
|
||||
let manager = authorization_manager_for(&initial_tokens).await?;
|
||||
let persistor = OAuthPersistor::new(
|
||||
initial_tokens.server_name.clone(),
|
||||
initial_tokens.url.clone(),
|
||||
manager.clone(),
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct),
|
||||
Some(initial_tokens.clone()),
|
||||
);
|
||||
let rmcp_tokens = tokens_with_credentials(
|
||||
initial_tokens.clone(),
|
||||
"rmcp-access-token",
|
||||
"rmcp-refresh-token",
|
||||
);
|
||||
install_tokens_in_manager(&manager, &rmcp_tokens).await?;
|
||||
Ok((initial_tokens, manager, persistor))
|
||||
}
|
||||
|
||||
fn tokens_with_credentials(
|
||||
mut tokens: StoredOAuthTokens,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> StoredOAuthTokens {
|
||||
tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_access_token(AccessToken::new(access_token.to_string()));
|
||||
tokens
|
||||
.token_response
|
||||
.0
|
||||
.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string())));
|
||||
tokens
|
||||
}
|
||||
|
||||
async fn authorization_manager_for(
|
||||
tokens: &StoredOAuthTokens,
|
||||
) -> Result<Arc<TokioMutex<AuthorizationManager>>> {
|
||||
|
||||
@@ -8,7 +8,6 @@ use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_config::types::AuthKeyringBackendKind;
|
||||
use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
use oauth2::TokenResponse;
|
||||
@@ -26,10 +25,6 @@ use super::ResolvedOAuthCredentialStore;
|
||||
use super::StoredOAuthTokens;
|
||||
use super::WrappedOAuthTokenResponse;
|
||||
use super::compute_expires_at_millis;
|
||||
use super::compute_store_key;
|
||||
use super::delete_oauth_tokens_from_direct_keyring;
|
||||
use super::delete_oauth_tokens_from_file;
|
||||
use super::delete_oauth_tokens_from_secrets_keyring;
|
||||
use super::load_oauth_tokens_from_file;
|
||||
use super::load_oauth_tokens_from_keyring;
|
||||
use super::refresh_lock::RefreshCredentialLock;
|
||||
@@ -43,7 +38,6 @@ const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
pub(crate) struct OAuthPersistor {
|
||||
inner: Arc<OAuthPersistorInner>,
|
||||
}
|
||||
|
||||
struct OAuthPersistorInner {
|
||||
server_name: String,
|
||||
url: String,
|
||||
@@ -111,79 +105,6 @@ impl OAuthPersistor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the latest stored credentials if they have changed.
|
||||
/// Deletes the credentials if they are no longer present.
|
||||
pub(crate) async fn persist_if_needed(&self) -> Result<()> {
|
||||
self.persist_if_needed_locked_with_keyring_store(&DefaultKeyringStore)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn persist_if_needed_locked_with_keyring_store<
|
||||
K: KeyringStore + Clone + 'static,
|
||||
>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
) -> Result<()> {
|
||||
let (snapshot, unpersisted_refresh) = {
|
||||
let state = self.inner.credential_state.lock().await;
|
||||
(state.current.clone(), state.unpersisted_refresh.clone())
|
||||
};
|
||||
let (client_id, current_credentials) = self.manager_credentials().await?;
|
||||
let manager_changed = match (&snapshot, current_credentials.as_ref()) {
|
||||
(Some(previous), Some(current)) => {
|
||||
previous.client_id != client_id
|
||||
|| previous.token_response != WrappedOAuthTokenResponse(current.clone())
|
||||
}
|
||||
(None, None) => false,
|
||||
(Some(_), None) | (None, Some(_)) => true,
|
||||
};
|
||||
if !manager_changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _lock =
|
||||
RefreshCredentialLock::acquire_for_server(&self.inner.server_name, &self.inner.url)
|
||||
.await?;
|
||||
let latest = self.load_resolved_credentials(keyring_store)?;
|
||||
|
||||
let latest_matches_snapshot = durable_credentials_match_snapshot(&latest, &snapshot)
|
||||
|| unpersisted_refresh.as_ref().is_some_and(|pending| {
|
||||
// The failed write never changed durable authority. If storage still contains the
|
||||
// last known durable snapshot, persist the manager's newer result instead of
|
||||
// adopting and replaying that stale snapshot.
|
||||
durable_credentials_match_snapshot(&latest, &pending.previously_persisted)
|
||||
});
|
||||
|
||||
if !latest_matches_snapshot {
|
||||
// A completed login or logout is authoritative over tokens refreshed inside an RMCP
|
||||
// operation. Adopt that state instead of allowing delayed post-operation persistence
|
||||
// to overwrite the login or resurrect the logout.
|
||||
match latest {
|
||||
Some(latest) => self.adopt_credentials(latest).await?,
|
||||
None => {
|
||||
self.clear_manager_credentials().await;
|
||||
let mut state = self.inner.credential_state.lock().await;
|
||||
state.current = None;
|
||||
state.unpersisted_refresh = None;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.persist_if_needed_with_keyring_store(keyring_store)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "AuthorizationManager async access must be serialized through its mutex"
|
||||
)]
|
||||
async fn manager_credentials(&self) -> Result<(String, Option<OAuthTokenResponse>)> {
|
||||
let manager = self.inner.authorization_manager.clone();
|
||||
let guard = manager.lock().await;
|
||||
guard.get_credentials().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn load_resolved_credentials<K: KeyringStore + Clone + 'static>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
@@ -207,128 +128,16 @@ impl OAuthPersistor {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "AuthorizationManager async access must be serialized through its mutex"
|
||||
)]
|
||||
pub(super) async fn persist_if_needed_with_keyring_store<K: KeyringStore + Clone + 'static>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
) -> Result<()> {
|
||||
let (client_id, maybe_credentials) = {
|
||||
let manager = self.inner.authorization_manager.clone();
|
||||
let guard = manager.lock().await;
|
||||
guard.get_credentials().await
|
||||
}?;
|
||||
|
||||
match maybe_credentials {
|
||||
Some(credentials) => {
|
||||
let mut state = self.inner.credential_state.lock().await;
|
||||
let new_token_response = WrappedOAuthTokenResponse(credentials.clone());
|
||||
let same_token = state
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|prev| prev.token_response == new_token_response)
|
||||
.unwrap_or(false);
|
||||
let expires_at = if same_token {
|
||||
state.current.as_ref().and_then(|prev| prev.expires_at)
|
||||
} else {
|
||||
compute_expires_at_millis(&credentials)
|
||||
};
|
||||
let stored = StoredOAuthTokens {
|
||||
server_name: self.inner.server_name.clone(),
|
||||
url: self.inner.url.clone(),
|
||||
client_id,
|
||||
token_response: new_token_response,
|
||||
expires_at,
|
||||
};
|
||||
if state.current.as_ref() != Some(&stored) {
|
||||
// The provider may already have consumed the old rotating refresh token. Make
|
||||
// B authoritative in this process before the fallible save so a later public
|
||||
// operation cannot reinstall A from the last snapshot.
|
||||
// Preserve the last snapshot known to be durable across repeated in-memory
|
||||
// changes. Using the immediately preceding in-memory token here could make an
|
||||
// unchanged stale store look like an external login or refresh.
|
||||
let previously_persisted = state
|
||||
.unpersisted_refresh
|
||||
.take()
|
||||
.map(|pending| pending.previously_persisted)
|
||||
.unwrap_or_else(|| state.current.clone());
|
||||
state.current = Some(stored.clone());
|
||||
state.unpersisted_refresh = Some(UnpersistedRefresh {
|
||||
previously_persisted,
|
||||
});
|
||||
debug!("persisting refreshed MCP OAuth credentials to the resolved store");
|
||||
let persistence_started_at = Instant::now();
|
||||
let persistence_result = match self.inner.credential_store {
|
||||
ResolvedOAuthCredentialStore::File => save_oauth_tokens_to_file(&stored),
|
||||
ResolvedOAuthCredentialStore::Keyring(keyring_backend_kind) => {
|
||||
save_oauth_tokens_with_keyring(
|
||||
keyring_store,
|
||||
keyring_backend_kind,
|
||||
&self.inner.server_name,
|
||||
&stored,
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Err(error) = persistence_result {
|
||||
warn!(
|
||||
persistence_elapsed_ms = persistence_started_at.elapsed().as_millis(),
|
||||
error = %error,
|
||||
"failed to persist refreshed MCP OAuth credentials; retaining them as the in-process authority without retrying persistence"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
state.unpersisted_refresh = None;
|
||||
debug!(
|
||||
persistence_elapsed_ms = persistence_started_at.elapsed().as_millis(),
|
||||
"persisted refreshed MCP OAuth credentials"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut state = self.inner.credential_state.lock().await;
|
||||
if state.current.take().is_some()
|
||||
&& let Err(error) = match self.inner.credential_store {
|
||||
ResolvedOAuthCredentialStore::File => {
|
||||
let key = compute_store_key(&self.inner.server_name, &self.inner.url)?;
|
||||
delete_oauth_tokens_from_file(&key).map(|_| ())
|
||||
}
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct) => {
|
||||
delete_oauth_tokens_from_direct_keyring(
|
||||
keyring_store,
|
||||
&self.inner.server_name,
|
||||
&self.inner.url,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Secrets) => {
|
||||
delete_oauth_tokens_from_secrets_keyring(
|
||||
keyring_store,
|
||||
&self.inner.server_name,
|
||||
&self.inner.url,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
{
|
||||
warn!(
|
||||
"failed to remove OAuth tokens for server {}: {error}",
|
||||
self.inner.server_name
|
||||
);
|
||||
}
|
||||
state.unpersisted_refresh = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_if_needed(&self) -> Result<()> {
|
||||
self.refresh_if_needed_with_keyring_store(&DefaultKeyringStore)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_after_unauthorized(&self) -> Result<()> {
|
||||
self.refresh_after_unauthorized_with_keyring_store(&DefaultKeyringStore)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_if_needed_with_keyring_store<K: KeyringStore + Clone + 'static>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
@@ -356,13 +165,32 @@ impl OAuthPersistor {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.run_owned_refresh_transaction(keyring_store.clone(), refresh_request_timeout)
|
||||
.await
|
||||
self.run_owned_refresh_transaction(
|
||||
keyring_store.clone(),
|
||||
RefreshReason::Expiry,
|
||||
refresh_request_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_after_unauthorized_with_keyring_store<
|
||||
K: KeyringStore + Clone + 'static,
|
||||
>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
) -> Result<()> {
|
||||
self.run_owned_refresh_transaction(
|
||||
keyring_store.clone(),
|
||||
RefreshReason::Unauthorized,
|
||||
REFRESH_REQUEST_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_owned_refresh_transaction<K: KeyringStore + Clone + 'static>(
|
||||
&self,
|
||||
keyring_store: K,
|
||||
reason: RefreshReason,
|
||||
refresh_request_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let persistor = self.clone();
|
||||
@@ -378,7 +206,7 @@ impl OAuthPersistor {
|
||||
// token-family-revocation risk rather than holding the lock indefinitely.
|
||||
tokio::spawn(async move {
|
||||
persistor
|
||||
.refresh_transaction(&keyring_store, refresh_request_timeout)
|
||||
.refresh_transaction(&keyring_store, reason, refresh_request_timeout)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
@@ -392,16 +220,27 @@ impl OAuthPersistor {
|
||||
#[tracing::instrument(
|
||||
level = "debug",
|
||||
skip_all,
|
||||
fields(server_name = %self.inner.server_name),
|
||||
fields(
|
||||
server_name = %self.inner.server_name,
|
||||
refresh_reason = reason.as_str(),
|
||||
),
|
||||
err
|
||||
)]
|
||||
async fn refresh_transaction<K: KeyringStore + Clone + 'static>(
|
||||
&self,
|
||||
keyring_store: &K,
|
||||
reason: RefreshReason,
|
||||
refresh_request_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let transaction_started_at = Instant::now();
|
||||
let lock_started_at = Instant::now();
|
||||
let local_access_token = {
|
||||
let state = self.inner.credential_state.lock().await;
|
||||
state
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|tokens| tokens.token_response.0.access_token().secret().to_string())
|
||||
};
|
||||
debug!("waiting for the MCP OAuth credential transaction lock");
|
||||
let _lock =
|
||||
RefreshCredentialLock::acquire_for_server(&self.inner.server_name, &self.inner.url)
|
||||
@@ -414,10 +253,11 @@ impl OAuthPersistor {
|
||||
let unpersisted_refresh = {
|
||||
let state = self.inner.credential_state.lock().await;
|
||||
if let Some(unpersisted_refresh) = state.unpersisted_refresh.as_ref() {
|
||||
if state
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !token_needs_refresh(tokens.expires_at))
|
||||
if matches!(reason, RefreshReason::Expiry)
|
||||
&& state
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|tokens| !token_needs_refresh(tokens.expires_at))
|
||||
{
|
||||
debug!(
|
||||
"using the memory-authoritative MCP OAuth credentials from a refresh whose persistence failed"
|
||||
@@ -464,67 +304,114 @@ impl OAuthPersistor {
|
||||
);
|
||||
};
|
||||
|
||||
if !token_needs_refresh(latest.expires_at) {
|
||||
let latest_access_token = latest.token_response.0.access_token().secret();
|
||||
// Expiry refresh can adopt any reread that is now healthy. A 401 is different: an
|
||||
// unexpired token is still rejected, so adopt only when another actor has already changed
|
||||
// the access token; otherwise force one serialized provider refresh.
|
||||
let should_adopt = !token_needs_refresh(latest.expires_at)
|
||||
&& match reason {
|
||||
RefreshReason::Expiry => true,
|
||||
RefreshReason::Unauthorized => {
|
||||
local_access_token.as_deref() != Some(latest_access_token)
|
||||
}
|
||||
};
|
||||
if should_adopt {
|
||||
self.adopt_credentials(latest).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.adopt_credentials(latest).await?;
|
||||
let manager = self.inner.authorization_manager.clone();
|
||||
let mut guard = manager.lock().await;
|
||||
install_tokens_in_manager_guard(&mut guard, &latest)
|
||||
.await
|
||||
.context("failed to stage OAuth credentials for refresh")?;
|
||||
let provider_started_at = Instant::now();
|
||||
debug!(
|
||||
timeout_ms = refresh_request_timeout.as_millis(),
|
||||
"requesting refreshed MCP OAuth credentials from the provider"
|
||||
);
|
||||
let refreshed = match timeout(refresh_request_timeout, guard.refresh_token()).await {
|
||||
Ok(Ok(token_response)) => {
|
||||
debug!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
"received refreshed MCP OAuth credentials from the provider"
|
||||
);
|
||||
refreshed_tokens(token_response, &latest, &self.inner)
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
error = %error,
|
||||
"MCP OAuth provider refresh failed"
|
||||
);
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to refresh OAuth tokens for server {}",
|
||||
self.inner.server_name
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
timeout_ms = refresh_request_timeout.as_millis(),
|
||||
"MCP OAuth provider refresh timed out; the outcome is unknown and a later serialized retry is permitted"
|
||||
);
|
||||
anyhow::bail!(
|
||||
"timed out after {refresh_request_timeout:?} refreshing OAuth tokens for server {}",
|
||||
self.inner.server_name
|
||||
);
|
||||
}
|
||||
};
|
||||
install_tokens_in_manager_guard(&mut guard, &refreshed)
|
||||
.await
|
||||
.context("failed to install refreshed OAuth credentials")?;
|
||||
drop(guard);
|
||||
|
||||
{
|
||||
let manager = self.inner.authorization_manager.clone();
|
||||
let guard = manager.lock().await;
|
||||
let provider_started_at = Instant::now();
|
||||
debug!(
|
||||
timeout_ms = refresh_request_timeout.as_millis(),
|
||||
"requesting refreshed MCP OAuth credentials from the provider"
|
||||
);
|
||||
match timeout(refresh_request_timeout, guard.refresh_token()).await {
|
||||
Ok(Ok(_token_response)) => {
|
||||
debug!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
"received refreshed MCP OAuth credentials from the provider"
|
||||
);
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
error = %error,
|
||||
"MCP OAuth provider refresh failed"
|
||||
);
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to refresh OAuth tokens for server {}",
|
||||
self.inner.server_name
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
provider_elapsed_ms = provider_started_at.elapsed().as_millis(),
|
||||
timeout_ms = refresh_request_timeout.as_millis(),
|
||||
"MCP OAuth provider refresh timed out; the outcome is unknown and a later serialized retry is permitted"
|
||||
);
|
||||
anyhow::bail!(
|
||||
"timed out after {refresh_request_timeout:?} refreshing OAuth tokens for server {}",
|
||||
self.inner.server_name
|
||||
);
|
||||
}
|
||||
let mut state = self.inner.credential_state.lock().await;
|
||||
state.current = Some(refreshed.clone());
|
||||
state.unpersisted_refresh = Some(UnpersistedRefresh {
|
||||
previously_persisted: Some(latest.clone()),
|
||||
});
|
||||
}
|
||||
debug!(
|
||||
"installed refreshed MCP OAuth credentials in memory and marked persistence pending"
|
||||
);
|
||||
// Once the provider rotates a refresh token, persistence must complete even if the caller's
|
||||
// deadline expires in the meantime. Returning early here would lose the only usable token.
|
||||
// Refresh persistence stays on the source resolved at client startup. In particular, a
|
||||
// keyring failure must surface instead of writing the rotated token to fallback File.
|
||||
debug!("persisting refreshed MCP OAuth credentials to the resolved store");
|
||||
let persistence_started_at = Instant::now();
|
||||
let persistence_result = match self.inner.credential_store {
|
||||
ResolvedOAuthCredentialStore::File => save_oauth_tokens_to_file(&refreshed),
|
||||
ResolvedOAuthCredentialStore::Keyring(keyring_backend_kind) => {
|
||||
save_oauth_tokens_with_keyring(
|
||||
keyring_store,
|
||||
keyring_backend_kind,
|
||||
&self.inner.server_name,
|
||||
&refreshed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the provider returns a rotated token, persistence must finish before the credential
|
||||
// lock is released. In particular, caller startup deadlines must not cancel this step.
|
||||
let result = self
|
||||
.persist_if_needed_with_keyring_store(keyring_store)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
debug!(
|
||||
};
|
||||
if let Err(error) = persistence_result {
|
||||
warn!(
|
||||
persistence_elapsed_ms = persistence_started_at.elapsed().as_millis(),
|
||||
transaction_elapsed_ms = transaction_started_at.elapsed().as_millis(),
|
||||
"completed the MCP OAuth refresh transaction"
|
||||
error = %error,
|
||||
"failed to persist refreshed MCP OAuth credentials; retaining them as the in-process authority without retrying persistence"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
result
|
||||
let mut state = self.inner.credential_state.lock().await;
|
||||
state.unpersisted_refresh = None;
|
||||
debug!(
|
||||
persistence_elapsed_ms = persistence_started_at.elapsed().as_millis(),
|
||||
transaction_elapsed_ms = transaction_started_at.elapsed().as_millis(),
|
||||
"persisted refreshed MCP OAuth credentials and completed the transaction"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn adopt_credentials(&self, tokens: StoredOAuthTokens) -> Result<()> {
|
||||
@@ -542,13 +429,37 @@ impl OAuthPersistor {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RefreshReason {
|
||||
Expiry,
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
impl RefreshReason {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Expiry => "expiry",
|
||||
Self::Unauthorized => "unauthorized",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "AuthorizationManager async access must be serialized through its mutex"
|
||||
)]
|
||||
pub(super) async fn install_tokens_in_manager(
|
||||
async fn install_tokens_in_manager(
|
||||
authorization_manager: &Arc<Mutex<AuthorizationManager>>,
|
||||
tokens: &StoredOAuthTokens,
|
||||
) -> Result<()> {
|
||||
let manager = authorization_manager.clone();
|
||||
let mut guard = manager.lock().await;
|
||||
install_tokens_in_manager_guard(&mut guard, tokens).await
|
||||
}
|
||||
|
||||
async fn install_tokens_in_manager_guard(
|
||||
authorization_manager: &mut AuthorizationManager,
|
||||
tokens: &StoredOAuthTokens,
|
||||
) -> Result<()> {
|
||||
let store = InMemoryCredentialStore::new();
|
||||
store
|
||||
@@ -556,13 +467,11 @@ pub(super) async fn install_tokens_in_manager(
|
||||
.await
|
||||
.context("failed to stage OAuth tokens for authorization manager")?;
|
||||
|
||||
let manager = authorization_manager.clone();
|
||||
let mut guard = manager.lock().await;
|
||||
guard.set_credential_store(store);
|
||||
authorization_manager.set_credential_store(store);
|
||||
// TODO(stevenlee): RMCP's `initialize_from_store` updates the credential store and client ID
|
||||
// but not its private `current_scopes`. Credential adoption can therefore leave scope-upgrade
|
||||
// state stale until RMCP exposes an adoption API that synchronizes both.
|
||||
guard
|
||||
authorization_manager
|
||||
.initialize_from_store()
|
||||
.await
|
||||
.context("failed to adopt refreshed OAuth tokens")?;
|
||||
@@ -587,3 +496,24 @@ fn stored_credentials_from_tokens(tokens: &StoredOAuthTokens) -> StoredCredentia
|
||||
token_received_at,
|
||||
)
|
||||
}
|
||||
|
||||
fn refreshed_tokens(
|
||||
mut token_response: OAuthTokenResponse,
|
||||
previous: &StoredOAuthTokens,
|
||||
inner: &OAuthPersistorInner,
|
||||
) -> StoredOAuthTokens {
|
||||
if token_response.refresh_token().is_none() {
|
||||
token_response.set_refresh_token(previous.token_response.0.refresh_token().cloned());
|
||||
}
|
||||
if token_response.scopes().is_none() {
|
||||
token_response.set_scopes(previous.token_response.0.scopes().cloned());
|
||||
}
|
||||
let expires_at = compute_expires_at_millis(&token_response);
|
||||
StoredOAuthTokens {
|
||||
server_name: inner.server_name.clone(),
|
||||
url: inner.url.clone(),
|
||||
client_id: previous.client_id.clone(),
|
||||
token_response: WrappedOAuthTokenResponse(token_response),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,15 @@ impl RefreshCredentialLock {
|
||||
}
|
||||
|
||||
pub(super) async fn acquire(store_key: &str) -> Result<Self> {
|
||||
Self::acquire_with_timeout(store_key, REFRESH_LOCK_ACQUIRE_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub(super) async fn acquire_with_timeout(
|
||||
store_key: &str,
|
||||
acquire_timeout: Duration,
|
||||
) -> Result<Self> {
|
||||
let codex_home = find_codex_home()?;
|
||||
Self::acquire_in(&codex_home, store_key, REFRESH_LOCK_ACQUIRE_TIMEOUT).await
|
||||
Self::acquire_in(&codex_home, store_key, acquire_timeout).await
|
||||
}
|
||||
|
||||
async fn acquire_in(
|
||||
|
||||
@@ -453,7 +453,7 @@ impl RmcpClient {
|
||||
|
||||
let mut initialize_deadline = timeout.map(|duration| Instant::now() + duration);
|
||||
let (service, oauth_persistor) = self
|
||||
.connect_pending_transport_with_initialize_retries(
|
||||
.connect_pending_transport_with_oauth_recovery(
|
||||
pending_transport,
|
||||
client_service.clone(),
|
||||
timeout,
|
||||
@@ -486,12 +486,6 @@ impl RmcpClient {
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(runtime) = oauth_persistor
|
||||
&& let Err(error) = runtime.persist_if_needed().await
|
||||
{
|
||||
warn!("failed to persist OAuth tokens after initialize: {error}");
|
||||
}
|
||||
|
||||
Ok(initialize_result)
|
||||
}
|
||||
|
||||
@@ -507,7 +501,6 @@ impl RmcpClient {
|
||||
async move { service.list_tools(params).await }.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -542,7 +535,6 @@ impl RmcpClient {
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(ListToolsWithConnectorIdResult {
|
||||
next_cursor: result.next_cursor,
|
||||
tools,
|
||||
@@ -569,7 +561,6 @@ impl RmcpClient {
|
||||
async move { service.list_resources(params).await }.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -585,7 +576,6 @@ impl RmcpClient {
|
||||
async move { service.list_resource_templates(params).await }.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -601,7 +591,6 @@ impl RmcpClient {
|
||||
async move { service.read_resource(params).await }.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -659,7 +648,6 @@ impl RmcpClient {
|
||||
.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -689,7 +677,6 @@ impl RmcpClient {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -712,7 +699,6 @@ impl RmcpClient {
|
||||
.boxed()
|
||||
})
|
||||
.await?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -752,16 +738,6 @@ impl RmcpClient {
|
||||
drop(previous_state);
|
||||
}
|
||||
|
||||
/// This should be called after every tool call so that if a given tool call triggered
|
||||
/// a refresh of the OAuth tokens, they are persisted.
|
||||
async fn persist_oauth_tokens(&self) {
|
||||
if let Some(runtime) = self.oauth_persistor().await
|
||||
&& let Err(error) = runtime.persist_if_needed().await
|
||||
{
|
||||
warn!("failed to persist OAuth tokens: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_oauth_if_needed(&self) -> Result<()> {
|
||||
if let Some(runtime) = self.oauth_persistor().await {
|
||||
runtime.refresh_if_needed().await?;
|
||||
@@ -968,19 +944,7 @@ impl RmcpClient {
|
||||
.await
|
||||
.map_err(|source| anyhow::Error::from(HandshakeError { source })),
|
||||
};
|
||||
let service = match service_result {
|
||||
Ok(service) => service,
|
||||
Err(error) => {
|
||||
if let Some(runtime) = oauth_persistor.as_ref()
|
||||
&& let Err(persist_error) = runtime.persist_if_needed().await
|
||||
{
|
||||
warn!(
|
||||
"failed to persist OAuth tokens after failed initialize: {persist_error}"
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let service = service_result?;
|
||||
|
||||
Ok((Arc::new(service), oauth_persistor))
|
||||
}
|
||||
@@ -995,38 +959,69 @@ impl RmcpClient {
|
||||
F: Fn(Arc<RunningService<RoleClient, ElicitationClientService>>) -> Fut,
|
||||
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
|
||||
{
|
||||
let deadline = timeout.map(|duration| Instant::now() + duration);
|
||||
let service = self.service().await?;
|
||||
match Self::run_service_operation_with_transient_retries(
|
||||
let mut result = Self::run_service_operation_with_transient_retries(
|
||||
Arc::clone(&service),
|
||||
label,
|
||||
timeout,
|
||||
deadline,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
if result
|
||||
.as_ref()
|
||||
.is_err_and(Self::is_unauthorized_operation_error)
|
||||
&& let Some(oauth_persistor) = self.oauth_persistor().await
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(error) if Self::is_session_expired_404(&error) => {
|
||||
self.reinitialize_after_session_expiry(&service).await?;
|
||||
let recovered_service = self.service().await?;
|
||||
Self::run_service_operation_with_transient_retries(
|
||||
recovered_service,
|
||||
label,
|
||||
timeout,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
// OAuth recovery is intentionally one-shot. The caller deadline gates whether recovery
|
||||
// starts and whether the retry runs, but it must not cancel an in-flight provider
|
||||
// refresh before a rotated token is persisted.
|
||||
remaining_operation_timeout(label, timeout, deadline)?;
|
||||
let refresh_result = oauth_persistor.refresh_after_unauthorized().await;
|
||||
if let Err(error) = refresh_result {
|
||||
if let Err(timeout_error) = remaining_operation_timeout(label, timeout, deadline) {
|
||||
return Err(timeout_error.into());
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
result = Self::run_service_operation_with_transient_retries(
|
||||
Arc::clone(&service),
|
||||
label,
|
||||
timeout,
|
||||
deadline,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if result.as_ref().is_err_and(Self::is_session_expired_404) {
|
||||
// Session recovery remains one-shot and runs after the optional OAuth retry, so a 401
|
||||
// followed by the old session's 404 still reconstructs the transport before retrying.
|
||||
self.reinitialize_after_session_expiry(&service).await?;
|
||||
let recovered_service = self.service().await?;
|
||||
result = Self::run_service_operation_with_transient_retries(
|
||||
recovered_service,
|
||||
label,
|
||||
timeout,
|
||||
deadline,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn run_service_operation_with_transient_retries<T, F, Fut>(
|
||||
service: Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
label: &str,
|
||||
timeout: Option<Duration>,
|
||||
retry_deadline: Option<Instant>,
|
||||
pause_state: ElicitationPauseState,
|
||||
operation: &F,
|
||||
) -> std::result::Result<T, ClientOperationError>
|
||||
@@ -1034,7 +1029,6 @@ impl RmcpClient {
|
||||
F: Fn(Arc<RunningService<RoleClient, ElicitationClientService>>) -> Fut,
|
||||
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
|
||||
{
|
||||
let retry_deadline = timeout.map(|duration| Instant::now() + duration);
|
||||
for (attempt, retry_delay_ms) in STREAMABLE_HTTP_RETRY_DELAYS_MS
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -1140,6 +1134,31 @@ impl RmcpClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_unauthorized_operation_error(error: &ClientOperationError) -> bool {
|
||||
let ClientOperationError::Service(rmcp::service::ServiceError::TransportSend(error)) =
|
||||
error
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
error
|
||||
.error
|
||||
.downcast_ref::<StreamableHttpError<StreamableHttpClientAdapterError>>()
|
||||
.is_some_and(Self::is_unauthorized_streamable_http_error)
|
||||
}
|
||||
|
||||
pub(super) fn is_unauthorized_streamable_http_error(
|
||||
error: &StreamableHttpError<StreamableHttpClientAdapterError>,
|
||||
) -> bool {
|
||||
match error {
|
||||
StreamableHttpError::AuthRequired(_) => true,
|
||||
StreamableHttpError::UnexpectedServerResponse(message) => {
|
||||
message.starts_with("HTTP 401")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reinitialize_after_session_expiry(
|
||||
&self,
|
||||
failed_service: &Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
@@ -1177,7 +1196,7 @@ impl RmcpClient {
|
||||
.timeout
|
||||
.map(|duration| Instant::now() + duration);
|
||||
let (service, oauth_persistor) = self
|
||||
.connect_pending_transport_with_initialize_retries(
|
||||
.connect_pending_transport_with_oauth_recovery(
|
||||
pending_transport,
|
||||
initialize_context.client_service,
|
||||
initialize_context.timeout,
|
||||
@@ -1196,12 +1215,6 @@ impl RmcpClient {
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(runtime) = oauth_persistor
|
||||
&& let Err(error) = runtime.persist_if_needed().await
|
||||
{
|
||||
warn!("failed to persist OAuth tokens after session recovery: {error}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,13 @@ use super::RmcpClient;
|
||||
const JSON_RPC_INTERNAL_ERROR_CODE: i64 = -32603;
|
||||
pub(super) const STREAMABLE_HTTP_RETRY_DELAYS_MS: [u64; 2] = [250, 1_000];
|
||||
|
||||
#[derive(Default)]
|
||||
struct InitializeAttemptContext {
|
||||
oauth_persistor: Option<OAuthPersistor>,
|
||||
}
|
||||
|
||||
impl RmcpClient {
|
||||
pub(super) async fn connect_pending_transport_with_initialize_retries(
|
||||
pub(super) async fn connect_pending_transport_with_oauth_recovery(
|
||||
&self,
|
||||
initial_transport: PendingTransport,
|
||||
client_service: ElicitationClientService,
|
||||
@@ -32,6 +37,69 @@ impl RmcpClient {
|
||||
) -> Result<(
|
||||
Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
Option<OAuthPersistor>,
|
||||
)> {
|
||||
let mut attempt_context = InitializeAttemptContext::default();
|
||||
match self
|
||||
.connect_pending_transport_with_initialize_retries(
|
||||
initial_transport,
|
||||
client_service.clone(),
|
||||
timeout,
|
||||
initialize_deadline,
|
||||
&mut attempt_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(error) if Self::is_unauthorized_initialize_error(&error) => {
|
||||
let Some(oauth_persistor) = attempt_context.oauth_persistor else {
|
||||
return Err(error);
|
||||
};
|
||||
// Initialization gets one OAuth refresh and one reconstructed transport. Reusing
|
||||
// this wrapper for the retry would turn persistent 401s into a refresh loop. The
|
||||
// startup deadline gates whether recovery starts and bounds transport setup plus
|
||||
// the retry handshake, but the refresh transaction has its own bounds and is
|
||||
// deliberately excluded from the startup budget.
|
||||
remaining_initialize_timeout(timeout, *initialize_deadline)?;
|
||||
let refresh_started_at = Instant::now();
|
||||
let refresh_result = oauth_persistor.refresh_after_unauthorized().await;
|
||||
if let Some(deadline) = initialize_deadline.as_mut() {
|
||||
*deadline += refresh_started_at.elapsed();
|
||||
}
|
||||
refresh_result?;
|
||||
let remaining = remaining_initialize_timeout(timeout, *initialize_deadline)?;
|
||||
let transport = match remaining {
|
||||
Some(remaining) => time::timeout(
|
||||
remaining,
|
||||
Self::create_pending_transport(&self.transport_recipe),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| initialize_timeout_error(timeout, remaining))??,
|
||||
None => Self::create_pending_transport(&self.transport_recipe).await?,
|
||||
};
|
||||
let mut retry_context = InitializeAttemptContext::default();
|
||||
self.connect_pending_transport_with_initialize_retries(
|
||||
transport,
|
||||
client_service,
|
||||
timeout,
|
||||
initialize_deadline,
|
||||
&mut retry_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_pending_transport_with_initialize_retries(
|
||||
&self,
|
||||
initial_transport: PendingTransport,
|
||||
client_service: ElicitationClientService,
|
||||
timeout: Option<Duration>,
|
||||
initialize_deadline: &mut Option<Instant>,
|
||||
attempt_context: &mut InitializeAttemptContext,
|
||||
) -> Result<(
|
||||
Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
Option<OAuthPersistor>,
|
||||
)> {
|
||||
let should_retry = match &initial_transport {
|
||||
PendingTransport::InProcess { .. } | PendingTransport::Stdio { .. } => false,
|
||||
@@ -62,6 +130,17 @@ impl RmcpClient {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Keep the persistor paired with the transport attempt that returned 401. Rebuilt
|
||||
// transports reuse the recipe's lifecycle-pinned credential source, and this pairing
|
||||
// also keeps the authorization manager and snapshot aligned with the failed attempt.
|
||||
attempt_context.oauth_persistor = match &transport {
|
||||
PendingTransport::StreamableHttpWithOAuth {
|
||||
oauth_persistor, ..
|
||||
} => Some(oauth_persistor.clone()),
|
||||
PendingTransport::InProcess { .. }
|
||||
| PendingTransport::Stdio { .. }
|
||||
| PendingTransport::StreamableHttp { .. } => None,
|
||||
};
|
||||
match Self::connect_pending_transport(
|
||||
transport,
|
||||
client_service.clone(),
|
||||
@@ -108,6 +187,29 @@ impl RmcpClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_unauthorized_initialize_error(error: &anyhow::Error) -> bool {
|
||||
error.chain().any(|source| {
|
||||
source
|
||||
.downcast_ref::<HandshakeError>()
|
||||
.is_some_and(|error| Self::is_unauthorized_client_initialize_error(&error.source))
|
||||
|| source
|
||||
.downcast_ref::<rmcp::service::ClientInitializeError>()
|
||||
.is_some_and(Self::is_unauthorized_client_initialize_error)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_unauthorized_client_initialize_error(
|
||||
error: &rmcp::service::ClientInitializeError,
|
||||
) -> bool {
|
||||
match error {
|
||||
rmcp::service::ClientInitializeError::TransportError { error, .. } => error
|
||||
.error
|
||||
.downcast_ref::<StreamableHttpError<StreamableHttpClientAdapterError>>()
|
||||
.is_some_and(Self::is_unauthorized_streamable_http_error),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable_client_initialize_error(error: &rmcp::service::ClientInitializeError) -> bool {
|
||||
match error {
|
||||
rmcp::service::ClientInitializeError::TransportError { error, context }
|
||||
|
||||
Reference in New Issue
Block a user