Skip to content

Commit be77938

Browse files
committed
fix(rmcp-client): reject unusable OAuth credentials
1 parent 0fbc4bf commit be77938

3 files changed

Lines changed: 175 additions & 17 deletions

File tree

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use reqwest::header::HeaderMap;
1212
use serde::Deserialize;
1313
use tracing::debug;
1414

15-
use crate::oauth::has_oauth_tokens;
15+
use crate::oauth::StoredOAuthTokenStatus;
16+
use crate::oauth::oauth_token_status;
1617
use crate::utils::apply_default_headers;
1718
use crate::utils::build_default_headers;
1819
use codex_config::types::OAuthCredentialsStoreMode;
@@ -44,8 +45,12 @@ pub async fn determine_streamable_http_auth_status(
4445
return Ok(McpAuthStatus::BearerToken);
4546
}
4647

47-
if has_oauth_tokens(server_name, url, store_mode)? {
48-
return Ok(McpAuthStatus::OAuth);
48+
match oauth_token_status(server_name, url, store_mode)? {
49+
StoredOAuthTokenStatus::Usable => return Ok(McpAuthStatus::OAuth),
50+
StoredOAuthTokenStatus::AuthorizationRequired => {
51+
return Ok(McpAuthStatus::NotLoggedIn);
52+
}
53+
StoredOAuthTokenStatus::Missing => {}
4954
}
5055

5156
match discover_streamable_http_oauth_with_headers(url, &default_headers).await {

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

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ impl PartialEq for WrappedOAuthTokenResponse {
7676
}
7777
}
7878

79+
#[derive(Debug, PartialEq, Eq)]
80+
pub(crate) enum StoredOAuthTokenStatus {
81+
Missing,
82+
Usable,
83+
AuthorizationRequired,
84+
}
85+
7986
pub(crate) fn load_oauth_tokens(
8087
server_name: &str,
8188
url: &str,
@@ -94,12 +101,33 @@ pub(crate) fn load_oauth_tokens(
94101
}
95102
}
96103

97-
pub(crate) fn has_oauth_tokens(
104+
pub(crate) fn oauth_token_status(
98105
server_name: &str,
99106
url: &str,
100107
store_mode: OAuthCredentialsStoreMode,
101-
) -> Result<bool> {
102-
Ok(load_oauth_tokens(server_name, url, store_mode)?.is_some())
108+
) -> Result<StoredOAuthTokenStatus> {
109+
Ok(
110+
match load_oauth_tokens(server_name, url, store_mode)?.as_ref() {
111+
None => StoredOAuthTokenStatus::Missing,
112+
Some(tokens) if oauth_tokens_are_usable(tokens) => StoredOAuthTokenStatus::Usable,
113+
Some(_) => StoredOAuthTokenStatus::AuthorizationRequired,
114+
},
115+
)
116+
}
117+
118+
fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool {
119+
if tokens.client_id.trim().is_empty() {
120+
return false;
121+
}
122+
123+
let token_response = &tokens.token_response.0;
124+
if token_needs_refresh(tokens.expires_at) {
125+
return token_response
126+
.refresh_token()
127+
.is_some_and(|token| !token.secret().trim().is_empty());
128+
}
129+
130+
!token_response.access_token().secret().trim().is_empty()
103131
}
104132

105133
fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) {
@@ -852,6 +880,84 @@ mod tests {
852880
assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO));
853881
}
854882

883+
#[test]
884+
fn oauth_tokens_are_usable_when_expiry_is_unknown() {
885+
let mut tokens = sample_tokens();
886+
tokens.expires_at = None;
887+
tokens.token_response.0.set_refresh_token(None);
888+
889+
assert!(super::oauth_tokens_are_usable(&tokens));
890+
}
891+
892+
#[test]
893+
fn oauth_tokens_are_usable_when_unexpired_without_refresh_token() {
894+
let mut tokens = sample_tokens();
895+
tokens.token_response.0.set_refresh_token(None);
896+
897+
assert!(super::oauth_tokens_are_usable(&tokens));
898+
}
899+
900+
#[test]
901+
fn oauth_tokens_are_usable_when_expired_but_refreshable() {
902+
let mut tokens = sample_tokens();
903+
tokens.expires_at = Some(0);
904+
905+
assert!(super::oauth_tokens_are_usable(&tokens));
906+
}
907+
908+
#[test]
909+
fn oauth_tokens_are_not_usable_when_expired_and_unrefreshable() {
910+
let mut tokens = sample_tokens();
911+
tokens.expires_at = Some(0);
912+
tokens.token_response.0.set_refresh_token(None);
913+
914+
assert!(!super::oauth_tokens_are_usable(&tokens));
915+
}
916+
917+
#[test]
918+
fn oauth_tokens_are_not_usable_when_near_expiry_and_unrefreshable() {
919+
let mut tokens = sample_tokens();
920+
let now = SystemTime::now()
921+
.duration_since(UNIX_EPOCH)
922+
.unwrap_or_else(|_| Duration::from_secs(0))
923+
.as_millis() as u64;
924+
tokens.expires_at = Some(now.saturating_add(REFRESH_SKEW_MILLIS - 1));
925+
tokens.token_response.0.set_refresh_token(None);
926+
927+
assert!(!super::oauth_tokens_are_usable(&tokens));
928+
}
929+
930+
#[test]
931+
fn oauth_tokens_are_not_usable_when_client_id_is_blank() {
932+
let mut tokens = sample_tokens();
933+
tokens.client_id = " ".to_string();
934+
935+
assert!(!super::oauth_tokens_are_usable(&tokens));
936+
}
937+
938+
#[test]
939+
fn oauth_tokens_are_not_usable_when_access_token_is_blank() {
940+
let mut tokens = sample_tokens();
941+
tokens
942+
.token_response
943+
.0
944+
.set_access_token(AccessToken::new(" ".to_string()));
945+
946+
assert!(!super::oauth_tokens_are_usable(&tokens));
947+
}
948+
949+
#[test]
950+
fn oauth_tokens_are_not_usable_when_required_refresh_token_is_blank() {
951+
let mut tokens = sample_tokens();
952+
tokens.expires_at = Some(0);
953+
tokens
954+
.token_response
955+
.0
956+
.set_refresh_token(Some(RefreshToken::new(" ".to_string())));
957+
958+
assert!(!super::oauth_tokens_are_usable(&tokens));
959+
}
960+
855961
fn assert_tokens_match_without_expiry(
856962
actual: &StoredOAuthTokens,
857963
expected: &StoredOAuthTokens,

codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod streamable_http_test_support;
22

33
use std::time::Duration;
4+
use std::time::SystemTime;
5+
use std::time::UNIX_EPOCH;
46

57
use codex_config::types::OAuthCredentialsStoreMode;
68
use codex_exec_server::Environment;
@@ -13,6 +15,7 @@ use codex_rmcp_client::save_oauth_tokens;
1315
use oauth2::AccessToken;
1416
use oauth2::RefreshToken;
1517
use oauth2::basic::BasicTokenType;
18+
use pretty_assertions::assert_eq;
1619
use rmcp::transport::auth::OAuthTokenResponse;
1720
use rmcp::transport::auth::VendorExtraTokenFields;
1821
use serde_json::Value;
@@ -36,6 +39,8 @@ const REFRESH_TOKEN: &str = "valid-refresh-token";
3639
const REFRESHED_ACCESS_TOKEN: &str = "refreshed-access-token";
3740
const CHILD_SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_STARTUP_SERVER_URL";
3841
const UNREFRESHABLE_SERVER_URL: &str = "https://unrefreshable.example/mcp";
42+
const UNEXPIRED_SERVER_URL: &str = "https://unexpired.example/mcp";
43+
const REFRESHABLE_SERVER_URL: &str = "https://refreshable.example/mcp";
3944

4045
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
4146
async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result<()> {
@@ -116,12 +121,12 @@ async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result
116121
}
117122

118123
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
119-
async fn reports_expired_unrefreshable_credentials_as_not_logged_in() -> anyhow::Result<()> {
124+
async fn reports_auth_status_for_persisted_credentials() -> anyhow::Result<()> {
120125
let codex_home = TempDir::new()?;
121126

122127
let status = Command::new(std::env::current_exe()?)
123128
.args([
124-
"expired_unrefreshable_auth_status_child",
129+
"persisted_credentials_auth_status_child",
125130
"--exact",
126131
"--ignored",
127132
"--nocapture",
@@ -132,14 +137,14 @@ async fn reports_expired_unrefreshable_credentials_as_not_logged_in() -> anyhow:
132137

133138
assert!(
134139
status.success(),
135-
"expired unrefreshable auth status child failed: {status}"
140+
"persisted credentials auth status child failed: {status}"
136141
);
137142
Ok(())
138143
}
139144

140145
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
141-
#[ignore = "spawned by reports_expired_unrefreshable_credentials_as_not_logged_in"]
142-
async fn expired_unrefreshable_auth_status_child() -> anyhow::Result<()> {
146+
#[ignore = "spawned by reports_auth_status_for_persisted_credentials"]
147+
async fn persisted_credentials_auth_status_child() -> anyhow::Result<()> {
143148
let response = OAuthTokenResponse::new(
144149
AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()),
145150
BasicTokenType::Bearer,
@@ -154,18 +159,60 @@ async fn expired_unrefreshable_auth_status_child() -> anyhow::Result<()> {
154159
};
155160
save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?;
156161

157-
let status = determine_streamable_http_auth_status(
162+
let status = auth_status(UNREFRESHABLE_SERVER_URL).await?;
163+
assert_eq!(status, McpAuthStatus::NotLoggedIn);
164+
165+
let response = OAuthTokenResponse::new(
166+
AccessToken::new("unexpired-access-token".to_string()),
167+
BasicTokenType::Bearer,
168+
VendorExtraTokenFields::default(),
169+
);
170+
let now = SystemTime::now()
171+
.duration_since(UNIX_EPOCH)
172+
.unwrap_or_else(|_| Duration::from_secs(0))
173+
.as_millis() as u64;
174+
let tokens = StoredOAuthTokens {
175+
server_name: SERVER_NAME.to_string(),
176+
url: UNEXPIRED_SERVER_URL.to_string(),
177+
client_id: "test-client-id".to_string(),
178+
token_response: WrappedOAuthTokenResponse(response),
179+
expires_at: Some(now.saturating_add(/*rhs*/ 60_000)),
180+
};
181+
save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?;
182+
183+
let status = auth_status(UNEXPIRED_SERVER_URL).await?;
184+
assert_eq!(status, McpAuthStatus::OAuth);
185+
186+
let mut response = OAuthTokenResponse::new(
187+
AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()),
188+
BasicTokenType::Bearer,
189+
VendorExtraTokenFields::default(),
190+
);
191+
response.set_refresh_token(Some(RefreshToken::new(REFRESH_TOKEN.to_string())));
192+
let tokens = StoredOAuthTokens {
193+
server_name: SERVER_NAME.to_string(),
194+
url: REFRESHABLE_SERVER_URL.to_string(),
195+
client_id: "test-client-id".to_string(),
196+
token_response: WrappedOAuthTokenResponse(response),
197+
expires_at: Some(0),
198+
};
199+
save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?;
200+
201+
let status = auth_status(REFRESHABLE_SERVER_URL).await?;
202+
assert_eq!(status, McpAuthStatus::OAuth);
203+
Ok(())
204+
}
205+
206+
async fn auth_status(server_url: &str) -> anyhow::Result<McpAuthStatus> {
207+
determine_streamable_http_auth_status(
158208
SERVER_NAME,
159-
UNREFRESHABLE_SERVER_URL,
209+
server_url,
160210
/*bearer_token_env_var*/ None,
161211
/*http_headers*/ None,
162212
/*env_http_headers*/ None,
163213
OAuthCredentialsStoreMode::File,
164214
)
165-
.await?;
166-
167-
assert_eq!(status, McpAuthStatus::NotLoggedIn);
168-
Ok(())
215+
.await
169216
}
170217

171218
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]

0 commit comments

Comments
 (0)