1use crate::cache::cache_dir;
2use crate::config::ServerConfig;
3use anyhow::{anyhow, bail};
4use rmcp::transport::auth::{
5 AuthError, AuthorizationManager, AuthorizationMetadata, AuthorizationRequest,
6 AuthorizationSession, CredentialStore, OAuthState, StoredCredentials,
7};
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13#[derive(Clone)]
17pub struct TokenStore {
18 server: String,
19 path: PathBuf,
20 lock: Arc<Mutex<()>>,
21}
22
23impl TokenStore {
24 pub fn new(server: &str, lock: Arc<Mutex<()>>) -> TokenStore {
25 TokenStore {
26 server: server.into(),
27 path: cache_dir().join("tokens.json"),
28 lock,
29 }
30 }
31
32 pub async fn stored_client_id(&self) -> Option<String> {
33 CredentialStore::load(self)
34 .await
35 .ok()
36 .flatten()
37 .map(|c| c.client_id)
38 }
39
40 fn read_all(&self) -> BTreeMap<String, StoredCredentials> {
41 std::fs::read_to_string(&self.path)
42 .ok()
43 .and_then(|t| serde_json::from_str(&t).ok())
44 .unwrap_or_default()
45 }
46
47 fn write_all(&self, m: &BTreeMap<String, StoredCredentials>) -> Result<(), AuthError> {
48 let io = |e: std::io::Error| AuthError::InternalError(e.to_string());
49 if let Some(dir) = self.path.parent() {
50 std::fs::create_dir_all(dir).map_err(io)?;
51 }
52 let tmp = self.path.with_extension("json.tmp");
53 let json = serde_json::to_string(m).map_err(|e| AuthError::InternalError(e.to_string()))?;
54 std::fs::write(&tmp, json).map_err(io)?;
55 #[cfg(unix)]
56 {
57 use std::os::unix::fs::PermissionsExt;
58 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).map_err(io)?;
59 }
60 std::fs::rename(&tmp, &self.path).map_err(io)?;
61 Ok(())
62 }
63}
64
65#[async_trait::async_trait]
66impl CredentialStore for TokenStore {
67 async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
68 let _g = self.lock.lock().await;
69 Ok(self.read_all().remove(&self.server))
70 }
71 async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> {
72 let _g = self.lock.lock().await;
73 let mut all = self.read_all();
74 all.insert(self.server.clone(), credentials);
75 self.write_all(&all)
76 }
77 async fn clear(&self) -> Result<(), AuthError> {
78 let _g = self.lock.lock().await;
79 let mut all = self.read_all();
80 all.remove(&self.server);
81 self.write_all(&all)
82 }
83}
84
85async fn discover_metadata_trusting_issuer(base: &str) -> anyhow::Result<AuthorizationMetadata> {
93 let base: reqwest::Url = base
94 .parse()
95 .map_err(|e| anyhow!("bad server url {base:?}: {e}"))?;
96 let trimmed = base.path().trim_start_matches('/').trim_end_matches('/');
97 let candidates: Vec<String> = if trimmed.is_empty() {
98 vec![
99 "/.well-known/oauth-authorization-server".into(),
100 "/.well-known/openid-configuration".into(),
101 ]
102 } else {
103 vec![
104 format!("/.well-known/oauth-authorization-server/{trimmed}"),
105 format!("/.well-known/openid-configuration/{trimmed}"),
106 format!("/{trimmed}/.well-known/openid-configuration"),
107 "/.well-known/oauth-authorization-server".into(),
108 ]
109 };
110 let client = reqwest::Client::new();
111 for path in &candidates {
112 let mut url = base.clone();
113 url.set_query(None);
114 url.set_fragment(None);
115 url.set_path(path);
116 let Ok(resp) = client.get(url).send().await else {
117 continue;
118 };
119 if resp.status() != reqwest::StatusCode::OK {
120 continue;
121 }
122 if let Ok(md) = resp.json::<AuthorizationMetadata>().await {
123 return Ok(md);
124 }
125 }
126 anyhow::bail!("oauth: no authorization server metadata found for {base}")
127}
128
129fn warn_relaxed_issuer(url: &str, md: &AuthorizationMetadata) {
130 tracing::warn!(
131 server_url = url,
132 issuer = md.issuer.as_deref().unwrap_or("<missing>"),
133 "oauth: issuer differs from the MCP server host; trusting discovered metadata (relaxed RFC 8414 issuer check)"
134 );
135}
136
137fn is_stale_client_error(e: &AuthError) -> bool {
141 matches!(
142 e,
143 AuthError::TokenRefreshFailed(m) | AuthError::TokenRefreshRejected(m)
144 if m.contains("invalid_client")
145 )
146}
147
148pub struct OAuth {
151 state: Mutex<Option<OAuthState>>,
152 listener: Mutex<Option<tokio::task::JoinHandle<()>>>,
153}
154
155impl OAuth {
156 pub fn new() -> Arc<OAuth> {
157 Arc::new(OAuth {
158 state: Mutex::new(None),
159 listener: Mutex::new(None),
160 })
161 }
162
163 async fn ensure_state(&self, cfg: &ServerConfig, store: &TokenStore) -> anyhow::Result<()> {
164 let mut g = self.state.lock().await;
165 if g.is_none() {
166 let url = cfg.url.as_deref().unwrap_or_default();
167 let mut m = AuthorizationManager::new(url)
168 .await
169 .map_err(|e| anyhow!("oauth init for {url}: {e}"))?;
170 m.set_credential_store(store.clone());
171 if let Err(e) = m.initialize_from_store().await {
172 if matches!(e, AuthError::AuthorizationServerMismatch { .. }) {
173 let md = discover_metadata_trusting_issuer(url).await?;
174 warn_relaxed_issuer(url, &md);
175 m.set_metadata(md);
176 m.initialize_from_store()
177 .await
178 .map_err(|e| anyhow!("oauth credential init: {e}"))?;
179 } else {
180 return Err(anyhow!("oauth credential init: {e}"));
181 }
182 }
183 *g = Some(OAuthState::Unauthorized(m));
184 }
185 Ok(())
186 }
187
188 pub async fn access_token(
191 &self,
192 cfg: &ServerConfig,
193 store: &TokenStore,
194 ) -> anyhow::Result<Option<String>> {
195 self.ensure_state(cfg, store).await?;
196 let g = self.state.lock().await;
197 let res = match g.as_ref().unwrap() {
203 OAuthState::Authorized(m) => m.get_access_token().await,
204 st => st.get_access_token().await,
205 };
206 match res {
207 Ok(t) => Ok(Some(t)),
208 Err(AuthError::AuthorizationRequired) => Ok(None),
209 Err(e) if is_stale_client_error(&e) => {
210 tracing::warn!(%e, "oauth: stored client registration rejected; clearing credentials to re-register");
211 store
212 .clear()
213 .await
214 .map_err(|e| anyhow!("oauth token: {e}"))?;
215 Ok(None)
216 }
217 Err(e) => Err(anyhow!("oauth token: {e}")),
218 }
219 }
220
221 pub async fn begin_flow(
225 self: &Arc<Self>,
226 cfg: &ServerConfig,
227 store: &TokenStore,
228 ) -> anyhow::Result<String> {
229 self.ensure_state(cfg, store).await?;
230 let mut g = self.state.lock().await;
231 {
232 let st = g.as_mut().unwrap();
233 if matches!(st, OAuthState::Session(_)) {
234 return st.get_authorization_url().await.map_err(|e| anyhow!("{e}"));
235 }
236 }
237 let port = cfg.oauth_redirect_port.unwrap_or(0);
239 let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
240 .await
241 .map_err(|e| anyhow!("cannot bind oauth callback on 127.0.0.1:{port}: {e}"))?;
242 let port = listener.local_addr()?.port();
243 let client_id = match &cfg.oauth_client_id {
244 Some(c) => Some(c.clone()),
245 None => store.stored_client_id().await,
246 };
247 let build_req = || {
248 let mut req = AuthorizationRequest::new(format!("http://127.0.0.1:{port}/callback"))
249 .with_client_name("mcp-multiplexer");
250 if !cfg.oauth_scopes.is_empty() {
251 req = req.with_scopes(cfg.oauth_scopes.clone());
252 }
253 if let Some(cid) = &client_id {
254 req = req.with_preregistered_client(cid.clone());
255 }
256 req
257 };
258 let start = g.as_mut().unwrap().start_authorization(build_req()).await;
259 match start {
260 Ok(()) => {}
261 Err(AuthError::AuthorizationServerMismatch { .. }) => {
262 let url = cfg.url.as_deref().unwrap_or_default();
265 let md = discover_metadata_trusting_issuer(url).await?;
266 warn_relaxed_issuer(url, &md);
267 let taken = g.take().unwrap();
268 let OAuthState::Unauthorized(mut m) = taken else {
269 *g = Some(taken);
270 bail!("oauth: unexpected state after failed discovery");
271 };
272 m.set_metadata(md);
273 match AuthorizationSession::new(m, build_req()).await {
274 Ok(session) => *g = Some(OAuthState::Session(session)),
275 Err((m, e)) => {
276 *g = Some(OAuthState::Unauthorized(m));
277 return Err(anyhow!("oauth: {e}"));
278 }
279 }
280 }
281 Err(e) => return Err(anyhow!("oauth: {e}")),
282 }
283 let url = g
284 .as_mut()
285 .unwrap()
286 .get_authorization_url()
287 .await
288 .map_err(|e| anyhow!("{e}"))?;
289 let me = self.clone();
290 let task = tokio::spawn(async move {
291 let Ok(callback_url) = wait_for_callback(listener).await else {
292 tracing::debug!("oauth callback listener closed without a redirect");
293 return;
294 };
295 let mut g = me.state.lock().await;
296 if let Some(st) = g.as_mut() {
297 match st.handle_callback_url(&callback_url).await {
298 Ok(()) => tracing::info!("oauth authorization completed"),
299 Err(e) => tracing::warn!(%e, "oauth callback exchange failed"),
300 }
301 }
302 });
303 *self.listener.lock().await = Some(task);
304 Ok(url)
305 }
306
307 pub async fn complete_with_url(&self, pasted: &str) -> anyhow::Result<()> {
309 let mut g = self.state.lock().await;
310 let Some(st) = g.as_mut() else {
311 bail!("no authorization in progress — call authorize_server without pasted_url first");
312 };
313 if !matches!(st, OAuthState::Session(_)) {
314 bail!("no authorization in progress — call authorize_server without pasted_url first");
315 }
316 st.handle_callback_url(pasted)
317 .await
318 .map_err(|e| anyhow!("redirect URL rejected: {e} — if this persists, restart the flow via authorize_server"))
319 }
320}
321
322fn request_path(line: &str) -> Option<&str> {
324 let mut parts = line.split_whitespace();
325 match (parts.next(), parts.next()) {
326 (Some("GET"), Some(p)) => Some(p),
327 _ => None,
328 }
329}
330
331async fn wait_for_callback(listener: tokio::net::TcpListener) -> anyhow::Result<String> {
334 use tokio::io::{AsyncReadExt, AsyncWriteExt};
335 let (mut sock, _) =
336 tokio::time::timeout(std::time::Duration::from_secs(600), listener.accept())
337 .await
338 .map_err(|_| anyhow!("oauth callback timed out after 10 minutes"))??;
339 let mut buf = Vec::with_capacity(1024);
340 let mut chunk = [0u8; 1024];
341 let line = loop {
342 let n = sock.read(&mut chunk).await?;
343 if n == 0 {
344 bail!("connection closed before request");
345 }
346 buf.extend_from_slice(&chunk[..n]);
347 if let Some(end) = buf.windows(2).position(|w| w == b"\r\n") {
348 break String::from_utf8_lossy(&buf[..end]).into_owned();
349 }
350 if buf.len() > 8192 {
351 bail!("callback request too large");
352 }
353 };
354 let path = request_path(&line).ok_or_else(|| anyhow!("not a GET request: {line:?}"))?;
355 let body = "<html><body><h3>mcp-multiplexer</h3><p>Authorization complete - you can close this tab.</p></body></html>";
356 sock.write_all(
357 format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
358 .as_bytes(),
359 )
360 .await?;
361 Ok(format!("http://127.0.0.1{path}"))
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn parses_request_line() {
370 assert_eq!(
371 request_path("GET /callback?code=abc&state=xyz HTTP/1.1"),
372 Some("/callback?code=abc&state=xyz")
373 );
374 assert_eq!(request_path("POST /callback HTTP/1.1"), None);
375 assert_eq!(request_path("garbage"), None);
376 }
377
378 #[test]
379 fn detects_stale_client_registration() {
380 let e = AuthError::TokenRefreshFailed(
381 "Server returned error response: invalid_client: Invalid client_id".into(),
382 );
383 assert!(is_stale_client_error(&e));
384 assert!(!is_stale_client_error(&AuthError::AuthorizationRequired));
385 assert!(!is_stale_client_error(&AuthError::TokenRefreshFailed(
386 "connection refused".into()
387 )));
388 }
389
390 #[tokio::test]
391 async fn relaxed_discovery_trusts_cross_host_issuer() {
392 use tokio::io::{AsyncReadExt, AsyncWriteExt};
393 let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
394 .await
395 .unwrap();
396 let port = listener.local_addr().unwrap().port();
397 let server = tokio::spawn(async move {
398 let body = r#"{"issuer":"https://auth.other-host.example","authorization_endpoint":"https://auth.other-host.example/authorize","token_endpoint":"https://auth.other-host.example/token"}"#;
399 while let Ok((mut sock, _)) = listener.accept().await {
400 let mut buf = [0u8; 2048];
401 let n = sock.read(&mut buf).await.unwrap();
402 let req = String::from_utf8_lossy(&buf[..n]);
403 let (status, body) =
406 if req.starts_with("GET /.well-known/oauth-authorization-server ") {
407 ("200 OK", body)
408 } else {
409 ("404 Not Found", "not found")
410 };
411 sock.write_all(
412 format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes(),
413 )
414 .await
415 .unwrap();
416 }
417 });
418 let md = discover_metadata_trusting_issuer(&format!("http://127.0.0.1:{port}/mcp"))
419 .await
420 .unwrap();
421 assert_eq!(
422 md.issuer.as_deref(),
423 Some("https://auth.other-host.example")
424 );
425 assert_eq!(md.token_endpoint, "https://auth.other-host.example/token");
426 server.abort();
427 }
428
429 #[tokio::test]
430 async fn token_store_roundtrip() {
431 let dir = std::env::temp_dir().join(format!("mcpmux-oauth-{}", std::process::id()));
432 std::fs::create_dir_all(&dir).unwrap();
433 let lock = Arc::new(Mutex::new(()));
434 let a = TokenStore {
435 server: "a".into(),
436 path: dir.join("tokens.json"),
437 lock: lock.clone(),
438 };
439 let b = TokenStore {
440 server: "b".into(),
441 path: dir.join("tokens.json"),
442 lock,
443 };
444 let creds = StoredCredentials::new("cid".into(), None, vec!["s1".into()], None);
445 CredentialStore::save(&a, creds).await.unwrap();
446 let got = CredentialStore::load(&a).await.unwrap().unwrap();
447 assert_eq!(got.client_id, "cid");
448 assert_eq!(got.granted_scopes, vec!["s1"]);
449 assert!(
450 CredentialStore::load(&b).await.unwrap().is_none(),
451 "servers must not see each other's tokens"
452 );
453 #[cfg(unix)]
454 {
455 use std::os::unix::fs::PermissionsExt;
456 assert_eq!(
457 std::fs::metadata(dir.join("tokens.json"))
458 .unwrap()
459 .permissions()
460 .mode()
461 & 0o777,
462 0o600
463 );
464 }
465 CredentialStore::clear(&a).await.unwrap();
466 assert!(CredentialStore::load(&a).await.unwrap().is_none());
467 std::fs::remove_dir_all(&dir).ok();
468 }
469}