Skip to main content

mcp_multiplexer/
oauth.rs

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/// Per-server view over `tokens.json` in the cache dir. The whole file is
14/// rewritten on every save; `lock` (shared per process) serializes that.
15/// ponytail: single-process tool — no cross-process refresh coordination.
16#[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
85/// rmcp enforces RFC 8414 §3.3: the discovered `issuer` must match the host the
86/// metadata was fetched from. Some providers delegate auth to a different
87/// domain than the MCP endpoint — the resource lives on one host, the declared
88/// `issuer` on another — and fail that check despite serving valid metadata.
89/// When strict discovery rejects the mismatch, re-fetch the same well-known
90/// candidates trusting the issuer the document itself declares.
91/// Same candidate order as rmcp's `generate_discovery_urls`.
92async 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
137/// ponytail: heuristic — rmcp surfaces a provider's invalid_client only as an
138/// error string; the registration is gone server-side (e.g. non-durable DCR),
139/// so drop client_id+tokens and let the next call re-register and re-authorize.
140fn 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
148/// OAuth state for one server: the rmcp state machine plus the pending
149/// callback-listener task of an in-flight authorization.
150pub 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    /// Ok(Some) = valid token (refreshed + persisted by the manager if needed),
189    /// Ok(None) = user authorization required, Err = infrastructure failure.
190    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        // rmcp quirk: OAuthState::get_access_token always errors "Already
198        // authorized" once the state machine reaches Authorized — the token is
199        // only reachable through the inner manager. An in-process browser flow
200        // lands in Authorized; fresh processes read via Unauthorized(manager)
201        // and never notice. Read the manager directly in that state.
202        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    /// Start (or re-report) the authorization flow; returns the URL to open.
222    /// The background task waits for the browser redirect on 127.0.0.1 and
223    /// completes the code exchange on its own.
224    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        // bind first: the port is part of the redirect URI
238        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                // rmcp's strict discovery restored Unauthorized state; redo
263                // discovery trustingly and build the session directly.
264                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    /// Headless completion: the user pastes the final redirect URL here.
308    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
322/// Extract the request path from an HTTP request line ("GET /cb?a=b HTTP/1.1").
323fn 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
331/// Wait (max 10 min) for the browser to hit the callback, answer with a
332/// "close this tab" page, and return the full redirect URL for parsing.
333async 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                // only the canonical well-known path serves metadata; the
404                // path-insertion candidates 404, exercising the fallback order
405                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}