Skip to content

Commit 7da5ed8

Browse files
authored
fix(mcp): bind stored OAuth tokens to server URLs
Merge the MCP OAuth token-binding hardening. - compare normalized server URL bindings without allocation - clarify legacy unbound token semantics - report expected vs stored bindings on refresh mismatch - reject storing OAuth tokens for servers without configured URLs - add regressions for unbound legacy refresh and URL-less token storage
1 parent b879980 commit 7da5ed8

2 files changed

Lines changed: 155 additions & 19 deletions

File tree

src-rust/crates/mcp/src/lib.rs

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,13 +1180,17 @@ impl McpManager {
11801180
}
11811181

11821182
if let Some(token) = oauth::get_mcp_token(server_name) {
1183-
if !token.is_expired(60) {
1183+
let token_matches_config = config
1184+
.url
1185+
.as_deref()
1186+
.is_some_and(|server_url| token.is_bound_to_server_url(server_url));
1187+
if token_matches_config && !token.is_expired(60) {
11841188
return McpAuthState::Authenticated {
11851189
token_expiry: token.expiry_datetime(),
11861190
};
11871191
}
11881192

1189-
if token.refresh_token.is_some() {
1193+
if token_matches_config && token.refresh_token.is_some() {
11901194
return McpAuthState::Required {
11911195
auth_url: format!(
11921196
"{} (refreshable token detected; connection will try to refresh automatically)",
@@ -1267,13 +1271,24 @@ impl McpManager {
12671271
.as_secs()
12681272
+ secs
12691273
});
1270-
let mcp_token = oauth::McpToken {
1271-
access_token: token.to_string(),
1272-
refresh_token: None,
1273-
expires_at,
1274-
scope: None,
1275-
server_name: server_name.to_string(),
1276-
};
1274+
let config = self
1275+
.server_configs
1276+
.get(server_name)
1277+
.ok_or_else(|| anyhow::anyhow!("Unknown MCP server: {}", server_name))?;
1278+
let server_url = config.url.as_deref().ok_or_else(|| {
1279+
anyhow::anyhow!(
1280+
"MCP server '{}' has no URL configured; OAuth tokens must be bound to a server URL",
1281+
server_name
1282+
)
1283+
})?;
1284+
let mcp_token = oauth::McpToken {
1285+
access_token: token.to_string(),
1286+
refresh_token: None,
1287+
expires_at,
1288+
scope: None,
1289+
server_name: server_name.to_string(),
1290+
server_url: Some(server_url.trim_end_matches('/').to_string()),
1291+
};
12771292
oauth::store_mcp_token(&mcp_token)
12781293
.map_err(|e| anyhow::anyhow!("Failed to store MCP token for '{}': {}", server_name, e))
12791294
}
@@ -1604,10 +1619,10 @@ mod tests {
16041619
}
16051620

16061621
#[test]
1607-
fn test_auth_state_uses_token_expiry_datetime() {
1608-
let mut mgr = McpManager::new();
1609-
mgr.server_configs.insert(
1610-
"remote".to_string(),
1622+
fn test_auth_state_uses_token_expiry_datetime() {
1623+
let mut mgr = McpManager::new();
1624+
mgr.server_configs.insert(
1625+
"remote".to_string(),
16111626
McpServerConfig {
16121627
name: "remote".to_string(),
16131628
command: None,
@@ -1629,6 +1644,7 @@ mod tests {
16291644
expires_at: Some(expires_at),
16301645
scope: None,
16311646
server_name: "remote".to_string(),
1647+
server_url: Some("https://example.com/mcp".to_string()),
16321648
};
16331649
oauth::store_mcp_token(&token).expect("store token");
16341650

@@ -1638,10 +1654,32 @@ mod tests {
16381654
}
16391655
other => panic!("expected authenticated, got {:?}", other),
16401656
}
1641-
1642-
oauth::remove_mcp_token("remote").ok();
1643-
}
1644-
}
1657+
1658+
oauth::remove_mcp_token("remote").ok();
1659+
}
1660+
1661+
#[test]
1662+
fn test_store_token_requires_server_url() {
1663+
let mut mgr = McpManager::new();
1664+
mgr.server_configs.insert(
1665+
"stdio".to_string(),
1666+
McpServerConfig {
1667+
name: "stdio".to_string(),
1668+
command: Some("node".to_string()),
1669+
args: vec![],
1670+
env: HashMap::new(),
1671+
url: None,
1672+
server_type: "stdio".to_string(),
1673+
},
1674+
);
1675+
1676+
let err = mgr
1677+
.store_token("stdio", "tok", None)
1678+
.expect_err("URL-less servers cannot store bound OAuth tokens");
1679+
1680+
assert!(err.to_string().contains("has no URL configured"));
1681+
}
1682+
}
16451683

16461684
// ---------------------------------------------------------------------------
16471685
// Resource subscriptions (T2-12)

src-rust/crates/mcp/src/oauth.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ pub struct McpToken {
2020
pub expires_at: Option<u64>,
2121
pub scope: Option<String>,
2222
pub server_name: String,
23+
/// Normalized MCP server URL this token was minted for.
24+
/// `None` means a legacy stored token is unbound and must re-authenticate.
25+
#[serde(default)]
26+
pub server_url: Option<String>,
2327
}
2428

2529
impl McpToken {
@@ -36,6 +40,13 @@ impl McpToken {
3640
pub fn expiry_datetime(&self) -> Option<chrono::DateTime<chrono::Utc>> {
3741
token_expiry_datetime(self.expires_at)
3842
}
43+
44+
pub fn is_bound_to_server_url(&self, server_url: &str) -> bool {
45+
self.server_url
46+
.as_deref()
47+
.map(normalized_server_url)
48+
== Some(normalized_server_url(server_url))
49+
}
3950
}
4051

4152
/// Path to the token store for a given MCP server.
@@ -97,6 +108,7 @@ pub struct McpOAuthMetadata {
97108
#[derive(Debug, Clone)]
98109
pub struct McpAuthSession {
99110
pub server_name: String,
111+
pub server_url: String,
100112
pub auth_url: String,
101113
pub redirect_uri: String,
102114
pub verifier: String,
@@ -115,6 +127,10 @@ fn normalized_server_url(server_url: &str) -> &str {
115127
server_url.trim_end_matches('/')
116128
}
117129

130+
fn normalize_server_url_owned(server_url: &str) -> String {
131+
normalized_server_url(server_url).to_string()
132+
}
133+
118134
fn fallback_oauth_metadata(server_url: &str) -> McpOAuthMetadata {
119135
let base_url = normalized_server_url(server_url);
120136
McpOAuthMetadata {
@@ -186,6 +202,7 @@ pub async fn begin_mcp_auth(
186202

187203
Ok(McpAuthSession {
188204
server_name: server_name.to_string(),
205+
server_url: normalize_server_url_owned(server_url),
189206
auth_url,
190207
redirect_uri,
191208
verifier,
@@ -295,6 +312,7 @@ pub async fn run_mcp_auth_session(session: McpAuthSession) -> anyhow::Result<Mcp
295312
)
296313
.await?;
297314
token.server_name = session.server_name.clone();
315+
token.server_url = Some(session.server_url.clone());
298316
store_mcp_token(&token).map_err(|e| {
299317
anyhow::anyhow!(
300318
"Failed to store MCP token for '{}': {}",
@@ -327,6 +345,10 @@ pub async fn get_valid_mcp_token(
327345
return Ok(None);
328346
};
329347

348+
if !token.is_bound_to_server_url(server_url) {
349+
return Ok(None);
350+
}
351+
330352
if !token.is_expired(60) {
331353
return Ok(Some(token));
332354
}
@@ -336,7 +358,7 @@ pub async fn get_valid_mcp_token(
336358
}
337359

338360
let metadata = fetch_oauth_metadata(server_url).await?;
339-
refresh_mcp_token(server_name, &metadata.token_endpoint)
361+
refresh_mcp_token(server_name, server_url, &metadata.token_endpoint)
340362
.await
341363
.map(Some)
342364
}
@@ -497,13 +519,32 @@ pub async fn exchange_code(
497519
expires_at,
498520
scope: tr.scope,
499521
server_name: String::new(), // caller should set this
522+
server_url: None, // caller should set this
500523
})
501524
}
502525

503526
/// Refresh an existing MCP token using the stored refresh token.
504-
pub async fn refresh_mcp_token(server_name: &str, token_endpoint: &str) -> anyhow::Result<McpToken> {
527+
pub async fn refresh_mcp_token(
528+
server_name: &str,
529+
server_url: &str,
530+
token_endpoint: &str,
531+
) -> anyhow::Result<McpToken> {
505532
let existing = get_mcp_token(server_name)
506533
.ok_or_else(|| anyhow::anyhow!("No stored token for {}", server_name))?;
534+
if !existing.is_bound_to_server_url(server_url) {
535+
let stored_binding = existing
536+
.server_url
537+
.as_deref()
538+
.map(normalized_server_url)
539+
.unwrap_or("<unbound legacy token>");
540+
anyhow::bail!(
541+
"Stored token for '{}' is not bound to requested MCP server URL (expected: {}, stored: {})",
542+
server_name,
543+
normalized_server_url(server_url),
544+
stored_binding
545+
);
546+
}
547+
507548
let refresh = existing
508549
.refresh_token
509550
.as_deref()
@@ -548,6 +589,7 @@ pub async fn refresh_mcp_token(server_name: &str, token_endpoint: &str) -> anyho
548589
expires_at,
549590
scope: existing.scope,
550591
server_name: server_name.to_string(),
592+
server_url: Some(normalize_server_url_owned(server_url)),
551593
};
552594

553595
store_mcp_token(&new_token)?;
@@ -575,10 +617,66 @@ mod tests {
575617
expires_at: Some(1), // expired long ago
576618
scope: None,
577619
server_name: "test".to_string(),
620+
server_url: Some("https://example.com/mcp".to_string()),
578621
};
579622
assert!(t.is_expired(0));
580623
}
581624

625+
#[test]
626+
fn token_server_url_binding_matches_normalized_url() {
627+
let t = McpToken {
628+
access_token: "tok".to_string(),
629+
refresh_token: None,
630+
expires_at: None,
631+
scope: None,
632+
server_name: "test".to_string(),
633+
server_url: Some("https://example.com/mcp".to_string()),
634+
};
635+
assert!(t.is_bound_to_server_url("https://example.com/mcp/"));
636+
assert!(!t.is_bound_to_server_url("https://attacker.example/mcp"));
637+
}
638+
639+
#[test]
640+
fn token_without_server_url_is_not_bound() {
641+
let t = McpToken {
642+
access_token: "tok".to_string(),
643+
refresh_token: None,
644+
expires_at: None,
645+
scope: None,
646+
server_name: "test".to_string(),
647+
server_url: None,
648+
};
649+
assert!(!t.is_bound_to_server_url("https://example.com/mcp"));
650+
}
651+
652+
#[tokio::test]
653+
async fn refresh_reports_unbound_legacy_token_binding() {
654+
let server_name = format!("legacy-unbound-{}", std::process::id());
655+
let _ = remove_mcp_token(&server_name);
656+
let token = McpToken {
657+
access_token: "tok".to_string(),
658+
refresh_token: Some("refresh".to_string()),
659+
expires_at: None,
660+
scope: None,
661+
server_name: server_name.clone(),
662+
server_url: None,
663+
};
664+
store_mcp_token(&token).expect("store legacy token");
665+
666+
let err = refresh_mcp_token(
667+
&server_name,
668+
"https://example.com/mcp/",
669+
"https://example.com/oauth/token",
670+
)
671+
.await
672+
.expect_err("unbound token should not refresh");
673+
let message = err.to_string();
674+
675+
assert!(message.contains("expected: https://example.com/mcp"));
676+
assert!(message.contains("stored: <unbound legacy token>"));
677+
let _ = remove_mcp_token(&server_name);
678+
}
679+
582680
#[test]
583681
fn fallback_metadata_uses_default_oauth_paths() {
584682
let metadata = fallback_oauth_metadata("https://example.com/mcp/");

0 commit comments

Comments
 (0)