Skip to content

Commit e4527fd

Browse files
committed
Route MCP OAuth recovery through Codex
1 parent c432fcd commit e4527fd

11 files changed

Lines changed: 1285 additions & 215 deletions

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use codex_exec_server::HttpResponseBodyStream;
2222
use futures::StreamExt;
2323
use futures::stream;
2424
use futures::stream::BoxStream;
25+
use oauth2::AccessToken;
2526
use reqwest::StatusCode;
2627
use reqwest::header::ACCEPT;
2728
use reqwest::header::AUTHORIZATION;
@@ -61,6 +62,10 @@ pub(crate) struct StreamableHttpClientAdapter {
6162
pub(crate) enum StreamableHttpClientAdapterError {
6263
#[error("streamable HTTP session expired with 404 Not Found")]
6364
SessionExpired404,
65+
#[error("MCP server rejected the access token with HTTP 401 Unauthorized")]
66+
AccessTokenRejected { rejected_access_token: AccessToken },
67+
#[error("MCP OAuth operation failed: {0:#}")]
68+
OAuth(#[source] anyhow::Error),
6469
#[error(transparent)]
6570
HttpRequest(#[from] ExecServerError),
6671
#[error("invalid HTTP header: {0}")]
@@ -109,7 +114,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
109114
JSON_MIME_TYPE.to_string(),
110115
StreamableHttpClientAdapterError::Header,
111116
)?;
112-
if let Some(auth_token) = auth_token {
117+
if let Some(auth_token) = auth_token.as_deref() {
113118
insert_header(
114119
&mut headers,
115120
AUTHORIZATION,
@@ -162,6 +167,11 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
162167
StreamableHttpClientAdapterError::SessionExpired404,
163168
));
164169
}
170+
if response.status == StatusCode::UNAUTHORIZED.as_u16()
171+
&& let Some(error) = access_token_rejected(auth_token.as_deref())
172+
{
173+
return Err(error);
174+
}
165175
if response.status == StatusCode::UNAUTHORIZED.as_u16()
166176
&& let Some(header) =
167177
response_header(&response.headers, reqwest::header::WWW_AUTHENTICATE)
@@ -240,7 +250,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
240250
let mut headers = self.default_headers.clone();
241251
headers.extend(custom_headers);
242252
self.add_auth_headers(&mut headers);
243-
if let Some(auth_token) = auth_token {
253+
if let Some(auth_token) = auth_token.as_deref() {
244254
insert_header(
245255
&mut headers,
246256
AUTHORIZATION,
@@ -274,6 +284,11 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
274284
if response.status == StatusCode::METHOD_NOT_ALLOWED.as_u16() {
275285
return Ok(());
276286
}
287+
if response.status == StatusCode::UNAUTHORIZED.as_u16()
288+
&& let Some(error) = access_token_rejected(auth_token.as_deref())
289+
{
290+
return Err(error);
291+
}
277292
if !status_is_success(response.status) {
278293
return Err(StreamableHttpError::UnexpectedServerResponse(
279294
format!("DELETE returned HTTP {}", response.status).into(),
@@ -316,7 +331,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
316331
StreamableHttpClientAdapterError::Header,
317332
)?;
318333
}
319-
if let Some(auth_token) = auth_token {
334+
if let Some(auth_token) = auth_token.as_deref() {
320335
insert_header(
321336
&mut headers,
322337
AUTHORIZATION,
@@ -349,6 +364,11 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
349364
StreamableHttpClientAdapterError::SessionExpired404,
350365
));
351366
}
367+
if response.status == StatusCode::UNAUTHORIZED.as_u16()
368+
&& let Some(error) = access_token_rejected(auth_token.as_deref())
369+
{
370+
return Err(error);
371+
}
352372
if !status_is_success(response.status) {
353373
return Err(StreamableHttpError::UnexpectedServerResponse(
354374
format!("GET returned HTTP {}", response.status).into(),
@@ -371,6 +391,20 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
371391
}
372392
}
373393

394+
fn access_token_rejected(
395+
auth_token: Option<&str>,
396+
) -> Option<StreamableHttpError<StreamableHttpClientAdapterError>> {
397+
// Preserve the token associated with this response. Reading the current credential after a
398+
// delayed 401 is racy: another concurrent request may already have refreshed A to B, in which
399+
// case recovery must retry B rather than refresh B a second time. AccessToken's Debug
400+
// implementation redacts the secret if this error is logged.
401+
auth_token.map(|rejected_access_token| {
402+
StreamableHttpError::Client(StreamableHttpClientAdapterError::AccessTokenRejected {
403+
rejected_access_token: AccessToken::new(rejected_access_token.to_string()),
404+
})
405+
})
406+
}
407+
374408
impl StreamableHttpClientAdapter {
375409
fn add_auth_headers(&self, headers: &mut HeaderMap) {
376410
if let Some(auth_provider) = &self.auth_provider {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod in_process_transport;
66
mod logging_client_handler;
77
mod oauth;
88
mod oauth_http_client;
9+
mod oauth_transport;
910
mod perform_oauth_login;
1011
mod program_resolver;
1112
mod rmcp_client;

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

Lines changed: 3 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,16 @@ use codex_keyring_store::DefaultKeyringStore;
6363
use codex_keyring_store::KeyringStore;
6464
use codex_utils_home_dir::find_codex_home;
6565

66+
pub(crate) use self::refresh_transaction::request_oauth_token_response;
6667
pub(crate) use self::resolved_store::ResolvedOAuthCredentialStore;
6768
pub(crate) use self::resolved_store::ResolvedOAuthTokens;
6869
pub(crate) use self::resolved_store::load_oauth_tokens_from_store;
6970
pub(crate) use self::resolved_store::resolve_oauth_tokens;
7071

7172
const KEYRING_SERVICE: &str = "Codex MCP Credentials";
7273
const MCP_OAUTH_SECRET_PREFIX: &str = "MCP_OAUTH";
73-
const REFRESH_SKEW_MILLIS: u64 = 30_000;
74+
// Refresh proactively so ordinary requests do not race token expiry.
75+
const REFRESH_SKEW_MILLIS: u64 = 60_000;
7476

7577
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7678
pub struct StoredOAuthTokens {
@@ -495,71 +497,6 @@ impl OAuthPersistor {
495497
}),
496498
}
497499
}
498-
499-
/// Persists a refresh that RMCP performed through its existing internal fallback path.
500-
///
501-
/// Codex preflight refreshes are serialized by `refresh_transaction`. RMCP keeps its current
502-
/// refresh capability until the next independently mergeable layer installs Codex recovery
503-
/// for every transport path, so this compatibility path must remain in place for now.
504-
#[expect(
505-
clippy::await_holding_invalid_type,
506-
reason = "AuthorizationManager async access must be serialized through its Tokio mutex"
507-
)]
508-
pub(crate) async fn persist_if_needed(&self) -> Result<()> {
509-
let (client_id, maybe_credentials) = {
510-
let manager = self.inner.authorization_manager.clone();
511-
let guard = manager.lock().await;
512-
guard.get_credentials().await
513-
}?;
514-
515-
match maybe_credentials {
516-
Some(credentials) => {
517-
let mut current_credentials = self.inner.current_credentials.lock().await;
518-
let new_token_response = WrappedOAuthTokenResponse(credentials.clone());
519-
let same_token = current_credentials
520-
.as_ref()
521-
.map(|previous| previous.token_response == new_token_response)
522-
.unwrap_or(false);
523-
let expires_at = if same_token {
524-
current_credentials
525-
.as_ref()
526-
.and_then(|previous| previous.expires_at)
527-
} else {
528-
compute_expires_at_millis(&credentials)
529-
};
530-
let stored = StoredOAuthTokens {
531-
server_name: self.inner.server_name.clone(),
532-
url: self.inner.url.clone(),
533-
client_id,
534-
token_response: new_token_response,
535-
expires_at,
536-
};
537-
if current_credentials.as_ref() != Some(&stored) {
538-
save_to_resolved_store(&DefaultKeyringStore, &self.inner, &stored)?;
539-
*current_credentials = Some(stored);
540-
}
541-
}
542-
None => {
543-
let mut current_credentials = self.inner.current_credentials.lock().await;
544-
if current_credentials.take().is_some()
545-
&& let Err(error) = delete_from_resolved_store(
546-
&DefaultKeyringStore,
547-
&self.inner.server_name,
548-
&self.inner.url,
549-
self.inner.credential_store,
550-
)
551-
{
552-
warn!(
553-
server_name = %self.inner.server_name,
554-
error = %error,
555-
"failed to remove MCP OAuth credentials from the resolved store"
556-
);
557-
}
558-
}
559-
}
560-
561-
Ok(())
562-
}
563500
}
564501

565502
fn save_to_resolved_store<K: KeyringStore + Clone + 'static>(
@@ -580,26 +517,6 @@ fn save_to_resolved_store<K: KeyringStore + Clone + 'static>(
580517
}
581518
}
582519

583-
fn delete_from_resolved_store<K: KeyringStore + Clone + 'static>(
584-
keyring_store: &K,
585-
server_name: &str,
586-
url: &str,
587-
credential_store: ResolvedOAuthCredentialStore,
588-
) -> Result<bool> {
589-
match credential_store {
590-
ResolvedOAuthCredentialStore::File => {
591-
let key = compute_store_key(server_name, url)?;
592-
delete_oauth_tokens_from_file(&key)
593-
}
594-
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct) => {
595-
delete_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)
596-
}
597-
ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Secrets) => {
598-
delete_oauth_tokens_from_secrets_keyring(keyring_store, server_name, url)
599-
}
600-
}
601-
}
602-
603520
const FALLBACK_FILENAME: &str = ".credentials.json";
604521
const MCP_SERVER_TYPE: &str = "http";
605522

0 commit comments

Comments
 (0)