Skip to main content

leviath_mcp/auth/
mod.rs

1//! OAuth 2.1 for MCP servers: browser login, token exchange, and refresh.
2//!
3//! MCP servers advertise themselves as OAuth *public clients* (no secret), so
4//! this implements the authorization-code flow with PKCE (RFC 7636) plus
5//! dynamic client registration (RFC 7591) and resource indicators (RFC 8707).
6//! A standards-correct implementation needs no per-server code.
7//!
8//! The interactive flow (browser + loopback redirect) lives in
9//! `OAuthClient::login`; `OAuthClient::refresh` is non-interactive so a
10//! background process can keep a session alive without ever opening a browser.
11
12// `pub(crate)` so the transport can reuse `same_origin` and
13// `is_safe_discovery_url` rather than growing a second opinion about what "the
14// server's own origin" means. The OAuth chain and the transport have to agree:
15// they are guarding the same token against the same server.
16pub(crate) mod metadata;
17mod pkce;
18pub mod store;
19
20use std::collections::HashMap;
21use std::time::Duration;
22
23use reqwest::Url;
24use serde::Deserialize;
25use tokio::net::TcpListener;
26
27use metadata::{AuthServerMetadata, ProtectedResourceMetadata};
28use pkce::Pkce;
29pub use store::{AuthStore, ServerAuth};
30
31/// How the browser gets opened. Injected so tests never launch one.
32///
33/// A boxed closure, not a bare `fn` pointer, so a platform binding can capture
34/// context: desktop passes `Arc::new(leviath_sys::open_url)`, a future mobile
35/// entry point passes an `Arc::new(move |url| ...)` closing over its native
36/// handle (an Android `Activity`, an iOS callback), and tests pass a stub that
37/// drives the callback directly. `Send + Sync` because it may be invoked from
38/// an async task. Returns whether the launcher spawned.
39pub type BrowserOpener = std::sync::Arc<dyn Fn(&str) -> bool + Send + Sync>;
40
41/// How long to wait for the user to finish authorizing in the browser.
42const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
43
44/// The scopes requested when the server advertises none.
45const DEFAULT_SCOPES: &str = "openid profile email";
46
47/// The token endpoint's response.
48#[derive(Debug, Deserialize)]
49struct TokenResponse {
50    access_token: String,
51    #[serde(default)]
52    refresh_token: Option<String>,
53    #[serde(default)]
54    expires_in: Option<u64>,
55    #[serde(default)]
56    scope: Option<String>,
57}
58
59/// A dynamic client registration response (only the id is needed).
60#[derive(Debug, Deserialize)]
61struct RegistrationResponse {
62    client_id: String,
63}
64
65/// What an MCP endpoint said when asked, with the configured headers, whether
66/// it wants credentials.
67enum Probe {
68    /// It asked for credentials: a `401`/`403`, a `WWW-Authenticate`, or both.
69    /// The header, when present, names where the metadata lives.
70    Challenge(Option<String>),
71    /// It answered the request as it stood, so nothing needs logging in. Either
72    /// the server is open, or the headers already configured for it are enough.
73    Satisfied,
74    /// It did not answer at all. Says nothing either way, so the caller carries
75    /// on to discovery, where a failure produces a message worth reading.
76    Unreachable,
77}
78
79#[cfg(test)]
80impl Probe {
81    /// A one-word name for the variant, so a test can assert on it with
82    /// `assert_eq!` rather than a `matches!` whose other arm never runs.
83    fn label(&self) -> &'static str {
84        match self {
85            Self::Challenge(_) => "challenge",
86            Self::Satisfied => "satisfied",
87            Self::Unreachable => "unreachable",
88        }
89    }
90}
91
92/// The result of [`OAuthClient::login`].
93#[derive(Debug)]
94pub enum LoginOutcome {
95    /// The browser flow ran and produced credentials worth storing.
96    ///
97    /// Boxed because it is much larger than the other variant, and every caller
98    /// stores it rather than reading it in place.
99    Authenticated(Box<ServerAuth>),
100    /// The server does not want an OAuth login. There is nothing to store, and
101    /// nothing went wrong.
102    NotRequired,
103}
104
105impl LoginOutcome {
106    /// The credentials this login produced, or `None` if none were needed.
107    ///
108    /// For a caller that only wants to store what came back and has nothing to
109    /// say about the other case.
110    pub fn authenticated(self) -> Option<ServerAuth> {
111        match self {
112            Self::Authenticated(auth) => Some(*auth),
113            Self::NotRequired => None,
114        }
115    }
116}
117
118/// Drives OAuth against one MCP server's authorization server.
119pub struct OAuthClient {
120    http: reqwest::Client,
121}
122
123impl Default for OAuthClient {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl OAuthClient {
130    /// Build a client with sensible network timeouts.
131    pub fn new() -> Self {
132        let http = reqwest::Client::builder()
133            .connect_timeout(Duration::from_secs(30))
134            .timeout(Duration::from_secs(60))
135            .build()
136            .expect("failed to build reqwest client");
137        Self { http }
138    }
139
140    /// Run the full interactive login for `mcp_url`.
141    ///
142    /// Returns [`LoginOutcome::NotRequired`] when the server answers a probe
143    /// carrying `headers` without demanding credentials. A server configured
144    /// with its own API token is the ordinary case: there is no OAuth flow to
145    /// run, and pushing one anyway is what sent every such server into a
146    /// discovery request it does not serve.
147    ///
148    /// `now` (Unix seconds) is passed in rather than read from the clock so the
149    /// computed `expires_at` is deterministic under test. `reuse_client_id`
150    /// short-circuits dynamic registration when a previous login already
151    /// registered this client with the authorization server.
152    pub async fn login(
153        &self,
154        mcp_url: &str,
155        headers: &HashMap<String, String>,
156        allow_env: &[String],
157        opener: BrowserOpener,
158        now: u64,
159        reuse_client_id: Option<&str>,
160    ) -> anyhow::Result<LoginOutcome> {
161        let mcp = Url::parse(mcp_url)
162            .map_err(|e| anyhow::anyhow!("Invalid MCP server url '{}': {}", mcp_url, e))?;
163
164        // An unreachable server still goes down the discovery path, which is
165        // where the useful error message comes from. Only a live answer that
166        // asked for nothing ends the flow here.
167        let www_authenticate = match self.probe_challenge(&mcp, headers, allow_env).await {
168            Probe::Satisfied => return Ok(LoginOutcome::NotRequired),
169            Probe::Challenge(header) => header,
170            Probe::Unreachable => None,
171        };
172
173        let (resource, server_meta) = self.discover(&mcp, www_authenticate.as_deref()).await?;
174
175        // Bind the loopback listener first, so its port is known before both
176        // registration (which needs the redirect URI) and the authorize URL.
177        // Binding an OS-assigned loopback port does not fail in practice; a
178        // failure here would mean the machine has no working loopback stack.
179        let listener = TcpListener::bind("127.0.0.1:0")
180            .await
181            .expect("binding an ephemeral loopback port cannot fail");
182        let port = listener
183            .local_addr()
184            .expect("a bound listener always has a local address")
185            .port();
186        let redirect_uri = format!("http://127.0.0.1:{port}/callback");
187
188        let client_id = match reuse_client_id {
189            Some(id) => id.to_string(),
190            None => self.register(&server_meta, &redirect_uri).await?,
191        };
192
193        let pkce = Pkce::generate();
194        let scope = if server_meta.scopes_supported.is_empty() {
195            DEFAULT_SCOPES.to_string()
196        } else {
197            server_meta.scopes_supported.join(" ")
198        };
199        // Already validated in `validate_auth_server_metadata`; `.expect` rather
200        // than a second fallible parse, which could not fail.
201        let authorize_endpoint = Url::parse(&server_meta.authorization_endpoint)
202            .expect("the authorization endpoint was parsed during metadata validation");
203        let authorize_url = build_authorize_url(
204            &authorize_endpoint,
205            &client_id,
206            &redirect_uri,
207            &pkce,
208            &scope,
209            &resource,
210        );
211
212        // Always print the URL: on a headless or SSH session the browser can't
213        // open, and the user needs to paste it themselves.
214        println!("Opening your browser to authorize:\n  {authorize_url}");
215        if !(*opener)(authorize_url.as_str()) {
216            println!("(couldn't open a browser automatically - open the link above)");
217        }
218
219        let code = wait_for_callback(listener, &pkce.state, CALLBACK_TIMEOUT).await?;
220
221        let token = self
222            .exchange_code(
223                &server_meta.token_endpoint,
224                &client_id,
225                &redirect_uri,
226                &code,
227                &pkce.verifier,
228                &resource,
229            )
230            .await?;
231
232        Ok(LoginOutcome::Authenticated(Box::new(build_server_auth(
233            resource,
234            &server_meta,
235            client_id,
236            token,
237            now,
238        ))))
239    }
240
241    /// Refresh `auth` non-interactively. Never opens a browser.
242    pub async fn refresh(&self, auth: &ServerAuth, now: u64) -> anyhow::Result<ServerAuth> {
243        let refresh_token = auth
244            .refresh_token
245            .as_deref()
246            .ok_or_else(|| anyhow::anyhow!("no refresh token available"))?;
247
248        let params = [
249            ("grant_type", "refresh_token"),
250            ("refresh_token", refresh_token),
251            ("client_id", auth.client_id.as_str()),
252            ("resource", auth.resource.as_str()),
253        ];
254        let value = self
255            .post_form(&auth.token_endpoint, &params)
256            .await
257            .map_err(|e| anyhow::anyhow!("token refresh failed: {}", e))?;
258        let token: TokenResponse = serde_json::from_value(value)
259            .map_err(|e| anyhow::anyhow!("could not parse token response: {}", e))?;
260
261        let mut refreshed = auth.clone();
262        refreshed.access_token = token.access_token;
263        // A refresh may or may not rotate the refresh token; keep the old one
264        // if the server did not send a new one.
265        if let Some(new_refresh) = token.refresh_token {
266            refreshed.refresh_token = Some(new_refresh);
267        }
268        refreshed.expires_at = expires_at(token.expires_in, now);
269        if let Some(scope) = token.scope {
270            refreshed.scope = scope;
271        }
272        Ok(refreshed)
273    }
274
275    /// Resolve the `Authorization` header for a stored server, refreshing the
276    /// token first if it is at or near expiry.
277    ///
278    /// Non-interactive: a dead refresh returns an error naming the login
279    /// command rather than opening a browser, so the daemon can call this
280    /// safely. A refreshed token is written back to `store_path`. Returns
281    /// `None` when the server has no stored auth (e.g. an unauthenticated
282    /// server, or one using a static header).
283    pub async fn authorization_header(
284        &self,
285        server_name: &str,
286        store_path: &std::path::Path,
287        now: u64,
288    ) -> anyhow::Result<Option<(String, String)>> {
289        self.authorization_header_with(server_name, store_path, now, None)
290            .await
291    }
292
293    /// [`authorization_header`](Self::authorization_header) reading and writing
294    /// grants through `credentials` - the OS credential store, when
295    /// `[security] credential_store = "keychain"` is set.
296    ///
297    /// `None` is the file backend. A refreshed token is written back through the
298    /// same backend it was read from, so a refresh in keychain mode does not
299    /// quietly land the new refresh token on disk.
300    pub async fn authorization_header_with(
301        &self,
302        server_name: &str,
303        store_path: &std::path::Path,
304        now: u64,
305        credentials: Option<&dyn leviath_core::CredentialStore>,
306    ) -> anyhow::Result<Option<(String, String)>> {
307        let mut store = AuthStore::load_with(store_path, credentials)?;
308        let Some(auth) = store.get(server_name) else {
309            return Ok(None);
310        };
311
312        let token = if auth.is_expired_at(now) {
313            let refreshed = self.refresh(auth, now).await.map_err(|e| {
314                anyhow::anyhow!(
315                    "MCP server '{server_name}' token expired and could not be \
316                     refreshed ({e}); re-authenticate with `lev mcp login {server_name}`"
317                )
318            })?;
319            let access = refreshed.access_token.clone();
320            store.set(server_name, refreshed);
321            store.save_with(store_path, credentials)?;
322            access
323        } else {
324            auth.access_token.clone()
325        };
326
327        Ok(Some((
328            "Authorization".to_string(),
329            format!("Bearer {token}"),
330        )))
331    }
332
333    /// Discover the resource identifier and authorization-server metadata.
334    async fn discover(
335        &self,
336        mcp: &Url,
337        www_authenticate: Option<&str>,
338    ) -> anyhow::Result<(String, AuthServerMetadata)> {
339        let hinted = metadata::resource_metadata_url(www_authenticate);
340        // The hint comes out of a header the *remote server* wrote, and whatever
341        // it names is then fetched by us, from inside the user's network. Bind it
342        // to the MCP server's own origin: a server may point at its own metadata
343        // document, which is the legitimate use, and may not point at anything
344        // else. Without this, connecting to a hostile MCP server was enough to
345        // make Leviath fetch an arbitrary URL - cloud metadata included.
346        let resource_meta_url = match hinted {
347            Some(hint) => {
348                let parsed = Url::parse(&hint)
349                    .map_err(|e| anyhow::anyhow!("invalid resource_metadata URL '{hint}': {e}"))?;
350                if !metadata::same_origin(&parsed, mcp) {
351                    anyhow::bail!(
352                        "MCP server at {mcp} pointed resource_metadata at a different origin \
353                         ({parsed}) - refusing to follow it"
354                    );
355                }
356                parsed
357            }
358            None => metadata::well_known_resource_url(mcp),
359        };
360        self.require_safe_discovery_url(&resource_meta_url)?;
361
362        let value = self
363            .get_json(resource_meta_url.as_str())
364            .await
365            .map_err(|e| anyhow::anyhow!("failed to fetch resource metadata: {}", e))?;
366        let resource_meta: ProtectedResourceMetadata = serde_json::from_value(value)
367            .map_err(|e| anyhow::anyhow!("failed to parse resource metadata: {}", e))?;
368
369        let issuer = resource_meta
370            .authorization_servers
371            .first()
372            .ok_or_else(|| anyhow::anyhow!("resource metadata names no authorization server"))?;
373        // Fall back to the MCP URL itself as the resource identifier if the
374        // document omits it (some servers do).
375        let resource = if resource_meta.resource.is_empty() {
376            mcp.to_string()
377        } else {
378            resource_meta.resource.clone()
379        };
380
381        let server_meta = self.fetch_auth_server_metadata(issuer).await?;
382        Ok((resource, server_meta))
383    }
384
385    /// Refuse a discovery URL that would carry a bearer token in cleartext.
386    fn require_safe_discovery_url(&self, url: &Url) -> anyhow::Result<()> {
387        match metadata::is_safe_discovery_url(url) {
388            true => Ok(()),
389            false => anyhow::bail!(
390                "refusing OAuth discovery over an insecure URL ({url}): the flow carries a \
391                 bearer token, so it must use https (http is permitted only on loopback)"
392            ),
393        }
394    }
395
396    /// Fetch AS metadata, trying RFC 8414 then the OpenID fallback.
397    ///
398    /// The returned document is validated against `issuer` before use. RFC 8414
399    /// §3.3 requires the `issuer` in the metadata to match the one that was
400    /// requested, and this never checked - so a hostile
401    /// `authorization_servers[0]` in the resource document could redirect the
402    /// entire flow to an attacker's authorization server and harvest the code.
403    async fn fetch_auth_server_metadata(&self, issuer: &str) -> anyhow::Result<AuthServerMetadata> {
404        let mut last_err = None;
405        // Parsed once here and passed down: `auth_server_metadata_urls` already
406        // parses `issuer` and errors on a bad one, so a second parse inside the
407        // validator could never fail.
408        let issuer_url = Url::parse(issuer)
409            .map_err(|e| anyhow::anyhow!("invalid authorization server issuer '{issuer}': {e}"))?;
410        for url in metadata::auth_server_metadata_urls(&issuer_url) {
411            self.require_safe_discovery_url(&url)?;
412            match self.fetch_one_auth_server_metadata(url.as_str()).await {
413                Ok(meta) => {
414                    self.validate_auth_server_metadata(&issuer_url, &meta)?;
415                    return Ok(meta);
416                }
417                Err(e) => last_err = Some(e),
418            }
419        }
420        Err(anyhow::anyhow!(
421            "failed to fetch authorization server metadata: {}",
422            last_err.expect("at least one candidate URL is always tried")
423        ))
424    }
425
426    /// Check a fetched AS metadata document against the issuer it claims to
427    /// describe.
428    ///
429    /// Three things, all of which a hostile document would otherwise get for
430    /// free:
431    ///
432    /// 1. The document's own `issuer` matches the one requested (RFC 8414 §3.3).
433    /// 2. The authorization and token endpoints share the issuer's origin, so a
434    ///    valid-looking document cannot send the user's browser - and the
435    ///    resulting code - somewhere else.
436    /// 3. Both endpoints are safe to use at all (https, or loopback).
437    fn validate_auth_server_metadata(
438        &self,
439        issuer_url: &Url,
440        meta: &AuthServerMetadata,
441    ) -> anyhow::Result<()> {
442        let issuer = issuer_url.as_str();
443
444        // RFC 8414 §3.3. Compared as parsed URLs so a trailing slash is not a
445        // spurious mismatch.
446        if !meta.issuer.is_empty() {
447            let claimed = Url::parse(&meta.issuer).map_err(|e| {
448                anyhow::anyhow!("invalid issuer '{}' in metadata: {e}", meta.issuer)
449            })?;
450            if !metadata::same_origin(&claimed, issuer_url) {
451                anyhow::bail!(
452                    "authorization server metadata claims issuer '{}' but was fetched for \
453                     '{issuer}' - refusing (RFC 8414 §3.3)",
454                    meta.issuer
455                );
456            }
457        }
458
459        for (label, endpoint) in [
460            ("authorization_endpoint", &meta.authorization_endpoint),
461            ("token_endpoint", &meta.token_endpoint),
462        ] {
463            let parsed = Url::parse(endpoint)
464                .map_err(|e| anyhow::anyhow!("invalid {label} '{endpoint}': {e}"))?;
465            self.require_safe_discovery_url(&parsed)?;
466            if !metadata::same_origin(&parsed, issuer_url) {
467                anyhow::bail!(
468                    "{label} '{endpoint}' is not on the issuer's origin ('{issuer}') - refusing"
469                );
470            }
471        }
472        Ok(())
473    }
474
475    /// Fetch and parse AS metadata from one candidate URL.
476    async fn fetch_one_auth_server_metadata(
477        &self,
478        url: &str,
479    ) -> anyhow::Result<AuthServerMetadata> {
480        let value = self.get_json(url).await?;
481        Ok(serde_json::from_value(value)?)
482    }
483
484    /// Probe the MCP endpoint with the configured headers to see whether it
485    /// demands OAuth at all.
486    ///
487    /// The headers matter: a server configured with an API token of its own
488    /// answers this request normally, and asking it to run a browser login
489    /// afterwards is asking for a second credential it never wanted.
490    async fn probe_challenge(
491        &self,
492        mcp: &Url,
493        headers: &HashMap<String, String>,
494        allow_env: &[String],
495    ) -> Probe {
496        let mut request = self.http.post(mcp.clone()).body("{}");
497        for (name, value) in headers {
498            // Expand exactly as the transport will. Probing with a literal
499            // `${TOKEN}` asks the server a question about a credential nobody
500            // will ever send it, and a server that checks the value answers
501            // `401` - sending a correctly configured client into an OAuth flow
502            // it does not need.
503            let value = crate::transport::http::expand_env_allowing(value, allow_env);
504            request = request.header(name, value);
505        }
506        let Ok(response) = request.send().await else {
507            return Probe::Unreachable;
508        };
509        let challenge = response
510            .headers()
511            .get(reqwest::header::WWW_AUTHENTICATE)
512            .and_then(|v| v.to_str().ok())
513            .map(str::to_string);
514        // A 401 or 403 is the server asking for credentials, whether or not it
515        // bothered to describe how. Anything else, with no challenge attached,
516        // means the request was accepted as it stood.
517        let demands_auth = matches!(
518            response.status(),
519            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
520        );
521        match (challenge, demands_auth) {
522            (Some(header), _) => Probe::Challenge(Some(header)),
523            (None, true) => Probe::Challenge(None),
524            (None, false) => Probe::Satisfied,
525        }
526    }
527
528    /// Register this client dynamically (RFC 7591), returning its id.
529    async fn register(
530        &self,
531        server_meta: &AuthServerMetadata,
532        redirect_uri: &str,
533    ) -> anyhow::Result<String> {
534        let endpoint = server_meta
535            .registration_endpoint
536            .as_deref()
537            .ok_or_else(|| {
538                anyhow::anyhow!(
539                    "authorization server does not support dynamic client registration; \
540                 a client id must be configured manually"
541                )
542            })?;
543
544        let body = serde_json::json!({
545            "client_name": "Leviath",
546            "redirect_uris": [redirect_uri],
547            "grant_types": ["authorization_code", "refresh_token"],
548            "response_types": ["code"],
549            "token_endpoint_auth_method": "none",
550        });
551        let response = self
552            .http
553            .post(endpoint)
554            .json(&body)
555            .send()
556            .await
557            .map_err(|e| anyhow::anyhow!("client registration request failed: {}", e))?;
558        if !response.status().is_success() {
559            let status = response.status();
560            let text = response.text().await.unwrap_or_default();
561            return Err(anyhow::anyhow!(
562                "client registration failed with HTTP {}: {}",
563                status,
564                text.trim()
565            ));
566        }
567        let registration: RegistrationResponse = response
568            .json()
569            .await
570            .map_err(|e| anyhow::anyhow!("failed to parse registration response: {}", e))?;
571        Ok(registration.client_id)
572    }
573
574    /// Exchange an authorization code for tokens.
575    async fn exchange_code(
576        &self,
577        token_endpoint: &str,
578        client_id: &str,
579        redirect_uri: &str,
580        code: &str,
581        verifier: &str,
582        resource: &str,
583    ) -> anyhow::Result<TokenResponse> {
584        let params = [
585            ("grant_type", "authorization_code"),
586            ("code", code),
587            ("redirect_uri", redirect_uri),
588            ("client_id", client_id),
589            ("code_verifier", verifier),
590            ("resource", resource),
591        ];
592        let value = self
593            .post_form(token_endpoint, &params)
594            .await
595            .map_err(|e| anyhow::anyhow!("token exchange failed: {}", e))?;
596        serde_json::from_value(value)
597            .map_err(|e| anyhow::anyhow!("could not parse token response: {}", e))
598    }
599
600    /// GET a URL and return its JSON body as a value.
601    ///
602    /// Non-generic on purpose: a `<T>` version generates a separate llvm-cov
603    /// instantiation per return type, and the error arms of the unused ones
604    /// read as uncovered. Callers deserialize the returned value concretely.
605    async fn get_json(&self, url: &str) -> anyhow::Result<serde_json::Value> {
606        let response = self.http.get(url).send().await?;
607        if !response.status().is_success() {
608            anyhow::bail!("HTTP {}", response.status());
609        }
610        Ok(response.json().await?)
611    }
612
613    /// POST a form and return the JSON response as a value, surfacing an OAuth
614    /// error body rather than a bare status. Non-generic for the same reason as
615    /// [`Self::get_json`].
616    async fn post_form(
617        &self,
618        url: &str,
619        params: &[(&str, &str)],
620    ) -> anyhow::Result<serde_json::Value> {
621        let response = self.http.post(url).form(params).send().await?;
622        let status = response.status();
623        let body = response.text().await.unwrap_or_default();
624        if !status.is_success() {
625            anyhow::bail!("HTTP {}: {}", status, body.trim());
626        }
627        serde_json::from_str(&body)
628            .map_err(|e| anyhow::anyhow!("could not parse token response: {}", e))
629    }
630}
631
632/// A [`crate::transport::BearerRefresher`] backed by the on-disk token store.
633///
634/// On a mid-session `401` the HTTP transport calls this: it refreshes the
635/// stored token non-interactively, persists the rotation, and hands back the
636/// new `Authorization` header value.
637pub struct StoredTokenRefresher {
638    server_name: String,
639    store_path: std::path::PathBuf,
640    /// Current Unix time; a fn so a long-lived transport stays current.
641    clock: fn() -> u64,
642}
643
644impl StoredTokenRefresher {
645    /// A refresher for `server_name`, reading and writing `store_path`.
646    pub fn new(server_name: impl Into<String>, store_path: std::path::PathBuf) -> Self {
647        Self {
648            server_name: server_name.into(),
649            store_path,
650            clock: system_now_secs,
651        }
652    }
653}
654
655/// Wall-clock Unix time in seconds.
656fn system_now_secs() -> u64 {
657    std::time::SystemTime::now()
658        .duration_since(std::time::UNIX_EPOCH)
659        .map(|d| d.as_secs())
660        .unwrap_or(0)
661}
662
663#[async_trait::async_trait]
664impl crate::transport::BearerRefresher for StoredTokenRefresher {
665    async fn refresh(&self) -> anyhow::Result<String> {
666        let mut store = AuthStore::load(&self.store_path)?;
667        let auth = store.get(&self.server_name).ok_or_else(|| {
668            anyhow::anyhow!(
669                "no stored credentials for MCP server '{}'",
670                self.server_name
671            )
672        })?;
673        let refreshed = OAuthClient::new().refresh(auth, (self.clock)()).await?;
674        let value = format!("Bearer {}", refreshed.access_token);
675        store.set(&self.server_name, refreshed);
676        store.save(&self.store_path)?;
677        Ok(value)
678    }
679}
680
681/// Compose the browser authorization URL.
682/// Takes an already-parsed endpoint rather than a string: by the time login
683/// reaches this, `validate_auth_server_metadata` has parsed the endpoint,
684/// required it to be https-or-loopback, and required it to share the issuer's
685/// origin. Re-parsing here would be a failure branch nothing can reach.
686fn build_authorize_url(
687    endpoint: &Url,
688    client_id: &str,
689    redirect_uri: &str,
690    pkce: &Pkce,
691    scope: &str,
692    resource: &str,
693) -> Url {
694    let mut url = endpoint.clone();
695    url.query_pairs_mut()
696        .append_pair("response_type", "code")
697        .append_pair("client_id", client_id)
698        .append_pair("redirect_uri", redirect_uri)
699        .append_pair("code_challenge", &pkce.challenge)
700        .append_pair("code_challenge_method", "S256")
701        .append_pair("state", &pkce.state)
702        .append_pair("scope", scope)
703        // RFC 8707: bind the issued token to this specific MCP server.
704        .append_pair("resource", resource);
705    url
706}
707
708/// Assemble the stored auth from a token response.
709fn build_server_auth(
710    resource: String,
711    server_meta: &AuthServerMetadata,
712    client_id: String,
713    token: TokenResponse,
714    now: u64,
715) -> ServerAuth {
716    ServerAuth {
717        resource,
718        issuer: server_meta.issuer.clone(),
719        authorization_endpoint: server_meta.authorization_endpoint.clone(),
720        token_endpoint: server_meta.token_endpoint.clone(),
721        client_id,
722        access_token: token.access_token,
723        refresh_token: token.refresh_token,
724        expires_at: expires_at(token.expires_in, now),
725        scope: token.scope.unwrap_or_default(),
726    }
727}
728
729/// Absolute expiry from a relative `expires_in`, or `0` (unknown) when the
730/// server omits it.
731fn expires_at(expires_in: Option<u64>, now: u64) -> u64 {
732    match expires_in {
733        Some(secs) => now.saturating_add(secs),
734        None => 0,
735    }
736}
737
738/// Accept the browser redirect on the loopback listener and return the code.
739///
740/// Validates `state` to reject a forged or replayed callback, replies with a
741/// human-friendly page, and gives up after [`CALLBACK_TIMEOUT`].
742async fn wait_for_callback(
743    listener: TcpListener,
744    expected_state: &str,
745    timeout: Duration,
746) -> anyhow::Result<String> {
747    let accept = async {
748        loop {
749            // Accepting on a freshly-bound loopback listener does not fail;
750            // connection resets surface later, on read, not here.
751            let (stream, _) = listener
752                .accept()
753                .await
754                .expect("accepting on a bound loopback listener cannot fail");
755            // A browser may make incidental requests (favicon, etc); only the
756            // one carrying our params counts.
757            if let Some(result) = handle_callback_connection(stream, expected_state).await? {
758                return Ok(result);
759            }
760        }
761    };
762
763    match tokio::time::timeout(timeout, accept).await {
764        Ok(result) => result,
765        Err(_) => Err(anyhow::anyhow!(
766            "timed out waiting for browser authorization"
767        )),
768    }
769}
770
771/// Handle one loopback connection.
772///
773/// Returns `Ok(Some(code))` for the authorization callback, `Ok(None)` for an
774/// unrelated request (so the caller keeps listening), and `Err` for a callback
775/// that arrived but was invalid (mismatched state, or an OAuth `error`).
776async fn handle_callback_connection(
777    mut stream: tokio::net::TcpStream,
778    expected_state: &str,
779) -> anyhow::Result<Option<String>> {
780    use tokio::io::AsyncReadExt;
781
782    let mut buf = vec![0u8; 8192];
783    let n = stream.read(&mut buf).await.unwrap_or(0);
784    let request = String::from_utf8_lossy(&buf[..n]);
785    let Some(target) = request_target(&request) else {
786        return Ok(None);
787    };
788    if !target.starts_with("/callback") {
789        write_response(&mut stream, "404 Not Found", "Not found.").await;
790        return Ok(None);
791    }
792
793    let params = query_params(target);
794    if let Some(error) = params.get("error") {
795        write_response(&mut stream, "400 Bad Request", "Authorization failed.").await;
796        return Err(anyhow::anyhow!("authorization server returned: {}", error));
797    }
798    match (params.get("code"), params.get("state")) {
799        // Constant-time: the state is 128 bits of fresh entropy over loopback, so
800        // a timing oracle here is theoretical - but it was the one secret
801        // comparison in the codebase still using `==`, and "theoretical" is not
802        // a reason for the comparison to differ from every other one.
803        (Some(code), Some(state)) if leviath_core::constant_time_eq(state, expected_state) => {
804            write_response(
805                &mut stream,
806                "200 OK",
807                "Authorization complete - you can close this tab and return to Leviath.",
808            )
809            .await;
810            Ok(Some(code.clone()))
811        }
812        (_, Some(_)) => {
813            // A state mismatch means a forged or stale callback.
814            write_response(
815                &mut stream,
816                "400 Bad Request",
817                "Invalid authorization state.",
818            )
819            .await;
820            Err(anyhow::anyhow!("OAuth state mismatch - rejecting callback"))
821        }
822        _ => {
823            write_response(
824                &mut stream,
825                "400 Bad Request",
826                "Missing authorization code.",
827            )
828            .await;
829            Err(anyhow::anyhow!("callback missing code or state"))
830        }
831    }
832}
833
834/// The request target (`/callback?…`) from an HTTP request line.
835fn request_target(request: &str) -> Option<&str> {
836    let line = request.lines().next()?;
837    let mut parts = line.split_whitespace();
838    let _method = parts.next()?;
839    parts.next()
840}
841
842/// Parse the query string of a request target into a map.
843fn query_params(target: &str) -> HashMap<String, String> {
844    let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
845    form_urlencoded::parse(query.as_bytes())
846        .map(|(k, v)| (k.into_owned(), v.into_owned()))
847        .collect()
848}
849
850/// Write a minimal HTML response and close the connection.
851async fn write_response(stream: &mut tokio::net::TcpStream, status: &str, message: &str) {
852    use tokio::io::AsyncWriteExt;
853    let body = format!("<!doctype html><meta charset=utf-8><p>{message}</p>");
854    let response = format!(
855        "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n\
856         Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
857        body.len()
858    );
859    let _ = stream.write_all(response.as_bytes()).await;
860    let _ = stream.flush().await;
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    // ─── build_authorize_url ──────────────────────────────────────────────
868
869    fn fixed_pkce() -> Pkce {
870        Pkce {
871            verifier: "verifier".to_string(),
872            challenge: "challenge".to_string(),
873            state: "state123".to_string(),
874        }
875    }
876
877    #[test]
878    fn authorize_url_carries_every_required_parameter() {
879        let url = build_authorize_url(
880            &Url::parse("https://auth.example.com/authorize").unwrap(),
881            "client-1",
882            "http://127.0.0.1:5000/callback",
883            &fixed_pkce(),
884            "openid profile",
885            "https://mcp.example.com/mcp",
886        );
887        let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
888        assert_eq!(params["response_type"], "code");
889        assert_eq!(params["client_id"], "client-1");
890        assert_eq!(params["redirect_uri"], "http://127.0.0.1:5000/callback");
891        assert_eq!(params["code_challenge"], "challenge");
892        assert_eq!(params["code_challenge_method"], "S256");
893        assert_eq!(params["state"], "state123");
894        assert_eq!(params["scope"], "openid profile");
895        // RFC 8707 resource binding is mandatory since MCP 2025-06-18.
896        assert_eq!(params["resource"], "https://mcp.example.com/mcp");
897    }
898
899    // `authorize_url_rejects_a_bad_endpoint` is gone with the `&str` parameter:
900    // `build_authorize_url` now takes an already-parsed `Url`, because
901    // `validate_auth_server_metadata` parses and origin-checks the endpoint
902    // before login ever gets here. An unparseable endpoint is covered end to end
903    // by `login_fails_when_the_authorize_endpoint_is_malformed`.
904
905    // ─── expires_at ───────────────────────────────────────────────────────
906
907    #[test]
908    fn expires_at_adds_the_relative_lifetime() {
909        assert_eq!(expires_at(Some(3600), 1_000), 4_600);
910    }
911
912    #[test]
913    fn expires_at_is_zero_when_unknown() {
914        assert_eq!(expires_at(None, 1_000), 0);
915    }
916
917    // ─── request parsing ──────────────────────────────────────────────────
918
919    #[test]
920    fn request_target_reads_the_path() {
921        assert_eq!(
922            request_target("GET /callback?code=abc HTTP/1.1\r\nHost: x\r\n\r\n"),
923            Some("/callback?code=abc")
924        );
925    }
926
927    #[test]
928    fn request_target_of_garbage_is_none() {
929        assert_eq!(request_target(""), None);
930        // A method with no target (the `?` on the second token).
931        assert_eq!(request_target("GET"), None);
932        // A whitespace-only line: a non-empty first line that yields no tokens.
933        assert_eq!(request_target("   \r\n"), None);
934    }
935
936    #[test]
937    fn query_params_parses_pairs() {
938        let params = query_params("/callback?code=abc&state=xyz");
939        assert_eq!(params["code"], "abc");
940        assert_eq!(params["state"], "xyz");
941    }
942
943    #[test]
944    fn query_params_of_a_bare_path_is_empty() {
945        assert!(query_params("/callback").is_empty());
946    }
947
948    // ─── build_server_auth ────────────────────────────────────────────────
949
950    fn server_meta() -> AuthServerMetadata {
951        serde_json::from_value(serde_json::json!({
952            "issuer": "https://auth.example.com",
953            "authorization_endpoint": "https://auth.example.com/authorize",
954            "token_endpoint": "https://auth.example.com/token",
955        }))
956        .unwrap()
957    }
958
959    #[test]
960    fn build_server_auth_populates_every_field() {
961        let token = TokenResponse {
962            access_token: "at".to_string(),
963            refresh_token: Some("rt".to_string()),
964            expires_in: Some(3600),
965            scope: Some("openid".to_string()),
966        };
967        let auth = build_server_auth(
968            "https://mcp.example.com/mcp".to_string(),
969            &server_meta(),
970            "client-1".to_string(),
971            token,
972            1_000,
973        );
974        assert_eq!(auth.resource, "https://mcp.example.com/mcp");
975        assert_eq!(auth.issuer, "https://auth.example.com");
976        assert_eq!(auth.client_id, "client-1");
977        assert_eq!(auth.access_token, "at");
978        assert_eq!(auth.refresh_token.as_deref(), Some("rt"));
979        assert_eq!(auth.expires_at, 4_600);
980        assert_eq!(auth.scope, "openid");
981    }
982
983    #[test]
984    fn build_server_auth_defaults_a_missing_scope() {
985        let token = TokenResponse {
986            access_token: "at".to_string(),
987            refresh_token: None,
988            expires_in: None,
989            scope: None,
990        };
991        let auth = build_server_auth(
992            "https://mcp".to_string(),
993            &server_meta(),
994            "c".to_string(),
995            token,
996            0,
997        );
998        assert_eq!(auth.scope, "");
999        assert_eq!(auth.expires_at, 0);
1000        assert!(auth.refresh_token.is_none());
1001    }
1002
1003    // ─── loopback callback handling ───────────────────────────────────────
1004    //
1005    // wait_for_callback binds a real listener; these drive it with a real TCP
1006    // client, exactly as a browser redirect would, so the accept loop, state
1007    // check, and response writing are all exercised without a browser.
1008
1009    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1010    use tokio::net::TcpStream;
1011
1012    /// Send one raw HTTP request line to `addr` and return the response text.
1013    async fn hit(addr: std::net::SocketAddr, target: &str) -> String {
1014        let mut stream = TcpStream::connect(addr).await.unwrap();
1015        let request = format!("GET {target} HTTP/1.1\r\nHost: localhost\r\n\r\n");
1016        stream.write_all(request.as_bytes()).await.unwrap();
1017        stream.flush().await.unwrap();
1018        let mut buf = Vec::new();
1019        let _ = stream.read_to_end(&mut buf).await;
1020        String::from_utf8_lossy(&buf).into_owned()
1021    }
1022
1023    #[tokio::test]
1024    async fn callback_returns_the_code_on_a_matching_state() {
1025        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1026        let addr = listener.local_addr().unwrap();
1027        let server = tokio::spawn(async move {
1028            wait_for_callback(listener, "st8", Duration::from_secs(5)).await
1029        });
1030
1031        let response = hit(addr, "/callback?code=the-code&state=st8").await;
1032        assert!(response.contains("200 OK"), "got: {response}");
1033        assert!(
1034            response.contains("Authorization complete"),
1035            "got: {response}"
1036        );
1037        assert_eq!(server.await.unwrap().unwrap(), "the-code");
1038    }
1039
1040    #[tokio::test]
1041    async fn callback_skips_unrelated_requests_then_accepts_the_real_one() {
1042        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1043        let addr = listener.local_addr().unwrap();
1044        let server = tokio::spawn(async move {
1045            wait_for_callback(listener, "st8", Duration::from_secs(5)).await
1046        });
1047
1048        // A browser often fetches /favicon.ico first; it must not end the wait.
1049        let favicon = hit(addr, "/favicon.ico").await;
1050        assert!(favicon.contains("404"), "got: {favicon}");
1051        let ok = hit(addr, "/callback?code=c&state=st8").await;
1052        assert!(ok.contains("200 OK"));
1053        assert_eq!(server.await.unwrap().unwrap(), "c");
1054    }
1055
1056    #[tokio::test]
1057    async fn callback_rejects_a_mismatched_state() {
1058        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1059        let addr = listener.local_addr().unwrap();
1060        let server = tokio::spawn(async move {
1061            wait_for_callback(listener, "expected", Duration::from_secs(5)).await
1062        });
1063
1064        let response = hit(addr, "/callback?code=c&state=forged").await;
1065        assert!(response.contains("400"), "got: {response}");
1066        let err = server.await.unwrap().expect_err("mismatch must fail");
1067        assert!(err.to_string().contains("state mismatch"), "got: {err}");
1068    }
1069
1070    #[tokio::test]
1071    async fn callback_surfaces_an_oauth_error() {
1072        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1073        let addr = listener.local_addr().unwrap();
1074        let server =
1075            tokio::spawn(
1076                async move { wait_for_callback(listener, "s", Duration::from_secs(5)).await },
1077            );
1078
1079        let response = hit(addr, "/callback?error=access_denied").await;
1080        assert!(response.contains("400"), "got: {response}");
1081        let err = server.await.unwrap().expect_err("error param must fail");
1082        assert!(err.to_string().contains("access_denied"), "got: {err}");
1083    }
1084
1085    #[tokio::test]
1086    async fn callback_rejects_a_request_missing_code_and_state() {
1087        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1088        let addr = listener.local_addr().unwrap();
1089        let server =
1090            tokio::spawn(
1091                async move { wait_for_callback(listener, "s", Duration::from_secs(5)).await },
1092            );
1093
1094        let response = hit(addr, "/callback?nothing=here").await;
1095        assert!(response.contains("400"), "got: {response}");
1096        assert!(server.await.unwrap().is_err());
1097    }
1098
1099    #[tokio::test]
1100    async fn handle_callback_ignores_an_empty_connection() {
1101        // A connection that sends nothing yields no request line, so it is
1102        // neither the callback nor an error - just skipped.
1103        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1104        let addr = listener.local_addr().unwrap();
1105        let accept = tokio::spawn(async move {
1106            let (stream, _) = listener.accept().await.unwrap();
1107            handle_callback_connection(stream, "s").await
1108        });
1109        // Connect and immediately close without writing.
1110        let stream = TcpStream::connect(addr).await.unwrap();
1111        drop(stream);
1112        let outcome = accept
1113            .await
1114            .unwrap()
1115            .expect("empty connection is not an error");
1116        assert!(outcome.is_none(), "an empty connection yields no code");
1117    }
1118
1119    // ─── full OAuth flows against a mock authorization server ─────────────
1120
1121    use axum::extract::State;
1122    use axum::http::StatusCode;
1123    use axum::response::IntoResponse;
1124    use axum::routing::{get, post};
1125    use axum::{Json, Router};
1126    use std::sync::Arc;
1127    use std::sync::atomic::{AtomicUsize, Ordering};
1128
1129    #[derive(Clone)]
1130    struct MockAs {
1131        base: String,
1132        registrations: Arc<AtomicUsize>,
1133    }
1134
1135    /// A standards-correct mock: protected-resource + AS metadata, dynamic
1136    /// registration, and a token endpoint. `variant` toggles which discovery
1137    /// quirks to exercise.
1138    async fn mock_auth_server(variant: &'static str) -> MockAs {
1139        let registrations = Arc::new(AtomicUsize::new(0));
1140        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1141        let base = format!("http://{}", listener.local_addr().unwrap());
1142        let state = MockAs {
1143            base: base.clone(),
1144            registrations: registrations.clone(),
1145        };
1146
1147        let app_state = (base.clone(), variant, registrations.clone());
1148        let app = Router::new()
1149            .route(
1150                "/mcp",
1151                post(|State((base, variant, _)): State<(String, &'static str, Arc<AtomicUsize>)>| async move {
1152                    // Unauthenticated probe → 401 with the resource hint
1153                    // pointing at this server's own well-known document.
1154                    let hint = if variant == "bad_hint" {
1155                        // Not a URL at all: the hint is server-controlled, so a
1156                        // malformed one must be rejected rather than parsed
1157                        // leniently.
1158                        "Bearer resource_metadata=\"not a url\"".to_string()
1159                    } else {
1160                        format!(
1161                            "Bearer resource_metadata=\"{base}/.well-known/oauth-protected-resource\""
1162                        )
1163                    };
1164                    (
1165                        StatusCode::UNAUTHORIZED,
1166                        [(reqwest::header::WWW_AUTHENTICATE, hint)],
1167                    )
1168                }),
1169            )
1170            .route(
1171                "/.well-known/oauth-protected-resource",
1172                get(|State((base, variant, _)): State<(String, &'static str, Arc<AtomicUsize>)>| async move {
1173                    if variant == "discover_fails" {
1174                        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
1175                    }
1176                    if variant == "resource_not_object" {
1177                        // Valid JSON, but not a ProtectedResourceMetadata object.
1178                        return Json(serde_json::json!("just a string")).into_response();
1179                    }
1180                    let resource = if variant == "no_resource" {
1181                        serde_json::Value::String(String::new())
1182                    } else {
1183                        serde_json::json!(format!("{base}/mcp"))
1184                    };
1185                    let servers = match variant {
1186                        "no_auth_server" => serde_json::json!([]),
1187                        "bad_issuer" => serde_json::json!(["not a url"]),
1188                        // A *remote* http issuer: the discovery URLs derived
1189                        // from it would carry a bearer token in cleartext.
1190                        "http_issuer" => serde_json::json!(["http://auth.example.com"]),
1191                        _ => serde_json::json!([base]),
1192                    };
1193                    Json(serde_json::json!({
1194                        "resource": resource,
1195                        "authorization_servers": servers,
1196                    }))
1197                    .into_response()
1198                }),
1199            )
1200            .route(
1201                "/.well-known/oauth-authorization-server",
1202                get(|State((base, variant, _)): State<(String, &'static str, Arc<AtomicUsize>)>| async move {
1203                    if variant == "no_rfc8414" || variant == "no_metadata" {
1204                        return StatusCode::NOT_FOUND.into_response();
1205                    }
1206                    if variant == "as_bad_rfc8414" {
1207                        // Valid JSON, but missing the required AS metadata
1208                        // fields, so parsing fails and discovery tries OpenID.
1209                        return Json(serde_json::json!({ "not": "metadata" })).into_response();
1210                    }
1211                    as_metadata(&base, variant).into_response()
1212                }),
1213            )
1214            .route(
1215                "/.well-known/openid-configuration",
1216                get(|State((base, variant, _)): State<(String, &'static str, Arc<AtomicUsize>)>| async move {
1217                    if variant == "no_metadata" {
1218                        return StatusCode::NOT_FOUND.into_response();
1219                    }
1220                    as_metadata(&base, variant).into_response()
1221                }),
1222            )
1223            .route(
1224                "/register",
1225                post(|State((base, variant, regs)): State<(String, &'static str, Arc<AtomicUsize>)>, _body: String| async move {
1226                    regs.fetch_add(1, Ordering::SeqCst);
1227                    let _ = base;
1228                    if variant == "register_fails" {
1229                        return (StatusCode::BAD_REQUEST, "invalid_redirect_uri").into_response();
1230                    }
1231                    if variant == "register_bad_json" {
1232                        return (StatusCode::OK, "not json").into_response();
1233                    }
1234                    Json(serde_json::json!({ "client_id": "registered-client" })).into_response()
1235                }),
1236            )
1237            .route(
1238                "/token",
1239                post(|State((_, variant, _)): State<(String, &'static str, Arc<AtomicUsize>)>, body: String| async move {
1240                    // A refresh with a bad token is the one failure we model.
1241                    if body.contains("refresh_token=bad") {
1242                        return (StatusCode::BAD_REQUEST, "invalid_grant").into_response();
1243                    }
1244                    if variant == "bad_token_json" {
1245                        return (StatusCode::OK, "not json").into_response();
1246                    }
1247                    if variant == "exchange_fails" && body.contains("authorization_code") {
1248                        return (StatusCode::BAD_REQUEST, "invalid_grant").into_response();
1249                    }
1250                    if variant == "minimal_token" {
1251                        return Json(serde_json::json!({ "access_token": "minimal" }))
1252                            .into_response();
1253                    }
1254                    if variant == "token_no_access" {
1255                        // Valid JSON, but not a TokenResponse (no access_token).
1256                        return Json(serde_json::json!({ "wat": true })).into_response();
1257                    }
1258                    Json(serde_json::json!({
1259                        "access_token": "new-access",
1260                        "refresh_token": "new-refresh",
1261                        "expires_in": 3600,
1262                        "scope": "openid",
1263                    }))
1264                    .into_response()
1265                }),
1266            )
1267            .with_state(app_state);
1268
1269        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1270            listener, app,
1271        )));
1272        state
1273    }
1274
1275    fn as_metadata(base: &str, variant: &'static str) -> Json<serde_json::Value> {
1276        let scopes: Vec<&str> = if variant == "no_scopes" {
1277            vec![]
1278        } else {
1279            vec!["openid", "profile"]
1280        };
1281        let authorize = match variant {
1282            "bad_authorize" => "not a url".to_string(),
1283            // A valid URL on somebody else's origin: the shape a hostile
1284            // document uses to send the user's browser - and the resulting
1285            // authorization code - somewhere the issuer does not control.
1286            "foreign_endpoint" => "https://evil.example.com/authorize".to_string(),
1287            // Remote *http*: refused for the scheme before the origin check
1288            // even runs.
1289            "http_endpoint" => "http://evil.example.com/authorize".to_string(),
1290            _ => format!("{base}/authorize"),
1291        };
1292        let issuer = match variant {
1293            // Claims to be a different issuer than the one we fetched it for.
1294            // RFC 8414 §3.3 requires these to match.
1295            "issuer_mismatch" => "https://someone-else.example.com".to_string(),
1296            // Distinct from the existing `bad_issuer`, which makes the
1297            // *resource* document name a non-URL authorization server. This one
1298            // is the AS document's own `issuer` field.
1299            "as_unparseable_issuer" => "not a url".to_string(),
1300            // Omitted entirely. The RFC 8414 §3.3 cross-check is skipped rather
1301            // than failing, since there is nothing to compare - the endpoint
1302            // origin check below still applies.
1303            "no_issuer_field" => String::new(),
1304            _ => base.to_string(),
1305        };
1306        let mut meta = serde_json::json!({
1307            "issuer": issuer,
1308            "authorization_endpoint": authorize,
1309            "token_endpoint": format!("{base}/token"),
1310            "scopes_supported": scopes,
1311        });
1312        if variant != "no_registration" {
1313            meta["registration_endpoint"] = serde_json::json!(format!("{base}/register"));
1314        }
1315        Json(meta)
1316    }
1317
1318    /// Drive the loopback redirect exactly as a browser would after consent.
1319    ///
1320    /// `state_override` forges the CSRF state (for the mismatch test);
1321    /// otherwise the real state from the authorize URL is echoed back. One
1322    /// spawn site shared by both consent stubs.
1323    fn drive_callback(authorize_url: &str, state_override: Option<&str>) {
1324        let url = Url::parse(authorize_url).unwrap();
1325        let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
1326        let redirect = params["redirect_uri"].clone();
1327        let state = state_override
1328            .map(String::from)
1329            .unwrap_or_else(|| params["state"].clone());
1330        // Spawned onto the same runtime; login is concurrently awaiting accept.
1331        tokio::spawn(async move {
1332            let callback = format!("{redirect}?code=auth-code&state={state}");
1333            let _ = reqwest::Client::new().get(&callback).send().await;
1334        });
1335    }
1336
1337    /// A fake browser that consents successfully.
1338    fn auto_consent() -> BrowserOpener {
1339        Arc::new(|authorize_url: &str| {
1340            drive_callback(authorize_url, None);
1341            true
1342        })
1343    }
1344
1345    #[tokio::test]
1346    async fn full_login_round_trip() {
1347        let server = mock_auth_server("default").await;
1348        let auth = OAuthClient::new()
1349            .login(
1350                &format!("{}/mcp", server.base),
1351                &HashMap::new(),
1352                &[],
1353                auto_consent(),
1354                1_000,
1355                None,
1356            )
1357            .await
1358            .expect("login should complete")
1359            .authenticated()
1360            .expect("the flow should have produced credentials");
1361
1362        assert_eq!(auth.access_token, "new-access");
1363        assert_eq!(auth.refresh_token.as_deref(), Some("new-refresh"));
1364        assert_eq!(auth.expires_at, 4_600);
1365        assert_eq!(auth.client_id, "registered-client");
1366        assert_eq!(server.registrations.load(Ordering::SeqCst), 1);
1367    }
1368
1369    #[tokio::test]
1370    async fn login_reuses_a_known_client_id_and_skips_registration() {
1371        let server = mock_auth_server("default").await;
1372        OAuthClient::new()
1373            .login(
1374                &format!("{}/mcp", server.base),
1375                &HashMap::new(),
1376                &[],
1377                auto_consent(),
1378                0,
1379                Some("existing-client"),
1380            )
1381            .await
1382            .expect("login should complete")
1383            .authenticated()
1384            .expect("the flow should have produced credentials");
1385        assert_eq!(
1386            server.registrations.load(Ordering::SeqCst),
1387            0,
1388            "a known client id must not re-register"
1389        );
1390    }
1391
1392    #[tokio::test]
1393    async fn login_falls_back_when_rfc8414_metadata_is_malformed() {
1394        // RFC 8414 returns unparseable metadata; discovery must recover via the
1395        // OpenID document rather than giving up.
1396        let server = mock_auth_server("as_bad_rfc8414").await;
1397        let auth = OAuthClient::new()
1398            .login(
1399                &format!("{}/mcp", server.base),
1400                &HashMap::new(),
1401                &[],
1402                auto_consent(),
1403                0,
1404                None,
1405            )
1406            .await
1407            .expect("openid recovery should work")
1408            .authenticated()
1409            .expect("the flow should have produced credentials");
1410        assert_eq!(auth.access_token, "new-access");
1411    }
1412
1413    #[tokio::test]
1414    async fn login_falls_back_to_openid_configuration() {
1415        // RFC 8414 404s, so discovery must try the OpenID document.
1416        let server = mock_auth_server("no_rfc8414").await;
1417        let auth = OAuthClient::new()
1418            .login(
1419                &format!("{}/mcp", server.base),
1420                &HashMap::new(),
1421                &[],
1422                auto_consent(),
1423                0,
1424                None,
1425            )
1426            .await
1427            .expect("openid fallback should work")
1428            .authenticated()
1429            .expect("the flow should have produced credentials");
1430        assert_eq!(auth.access_token, "new-access");
1431    }
1432
1433    #[tokio::test]
1434    async fn login_fails_when_registration_is_unsupported() {
1435        let server = mock_auth_server("no_registration").await;
1436        let err = OAuthClient::new()
1437            .login(
1438                &format!("{}/mcp", server.base),
1439                &HashMap::new(),
1440                &[],
1441                auto_consent(),
1442                0,
1443                None,
1444            )
1445            .await
1446            .expect_err("no registration endpoint and no client id must fail");
1447        assert!(
1448            err.to_string().contains("dynamic client registration"),
1449            "got: {err}"
1450        );
1451    }
1452
1453    #[tokio::test]
1454    async fn refresh_rotates_the_tokens() {
1455        let server = mock_auth_server("default").await;
1456        let auth = ServerAuth {
1457            resource: format!("{}/mcp", server.base),
1458            issuer: server.base.clone(),
1459            authorization_endpoint: format!("{}/authorize", server.base),
1460            token_endpoint: format!("{}/token", server.base),
1461            client_id: "c".to_string(),
1462            access_token: "old".to_string(),
1463            refresh_token: Some("good".to_string()),
1464            expires_at: 500,
1465            scope: String::new(),
1466        };
1467        let refreshed = OAuthClient::new().refresh(&auth, 2_000).await.unwrap();
1468        assert_eq!(refreshed.access_token, "new-access");
1469        assert_eq!(refreshed.refresh_token.as_deref(), Some("new-refresh"));
1470        assert_eq!(refreshed.expires_at, 5_600);
1471    }
1472
1473    #[tokio::test]
1474    async fn refresh_keeps_the_old_token_when_none_is_returned() {
1475        // A server that returns only an access_token must not wipe the refresh
1476        // token or scope we already hold.
1477        let server = mock_auth_server("minimal_token").await;
1478        let auth = ServerAuth {
1479            token_endpoint: format!("{}/token", server.base),
1480            refresh_token: Some("keep-me".to_string()),
1481            scope: "openid".to_string(),
1482            ..Default::default()
1483        };
1484        let refreshed = OAuthClient::new().refresh(&auth, 0).await.unwrap();
1485        assert_eq!(refreshed.access_token, "minimal");
1486        assert_eq!(refreshed.refresh_token.as_deref(), Some("keep-me"));
1487        assert_eq!(refreshed.scope, "openid");
1488        assert_eq!(refreshed.expires_at, 0);
1489    }
1490
1491    #[tokio::test]
1492    async fn authorization_header_is_none_without_stored_auth() {
1493        let dir = tempfile::tempdir().unwrap();
1494        let store = dir.path().join("mcp-auth.json");
1495        let header = OAuthClient::new()
1496            .authorization_header("unknown", &store, 0)
1497            .await
1498            .unwrap();
1499        assert!(header.is_none());
1500    }
1501
1502    #[tokio::test]
1503    async fn authorization_header_returns_a_fresh_token_unchanged() {
1504        let dir = tempfile::tempdir().unwrap();
1505        let store_path = dir.path().join("mcp-auth.json");
1506        let mut store = AuthStore::default();
1507        store.set(
1508            "srv",
1509            ServerAuth {
1510                access_token: "still-good".to_string(),
1511                expires_at: 10_000,
1512                ..Default::default()
1513            },
1514        );
1515        store.save(&store_path).unwrap();
1516
1517        let header = OAuthClient::new()
1518            .authorization_header("srv", &store_path, 1_000)
1519            .await
1520            .unwrap()
1521            .expect("a stored token yields a header");
1522        assert_eq!(
1523            header,
1524            ("Authorization".to_string(), "Bearer still-good".to_string())
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn authorization_header_refreshes_an_expired_token_and_persists_it() {
1530        let server = mock_auth_server("default").await;
1531        let dir = tempfile::tempdir().unwrap();
1532        let store_path = dir.path().join("mcp-auth.json");
1533        let mut store = AuthStore::default();
1534        store.set(
1535            "srv",
1536            ServerAuth {
1537                token_endpoint: format!("{}/token", server.base),
1538                access_token: "expired".to_string(),
1539                refresh_token: Some("good".to_string()),
1540                expires_at: 100,
1541                ..Default::default()
1542            },
1543        );
1544        store.save(&store_path).unwrap();
1545
1546        let header = OAuthClient::new()
1547            .authorization_header("srv", &store_path, 1_000)
1548            .await
1549            .unwrap()
1550            .expect("an expired token is refreshed");
1551        assert_eq!(header.1, "Bearer new-access");
1552        // The rotated token is written back for next time.
1553        let reloaded = AuthStore::load(&store_path).unwrap();
1554        assert_eq!(reloaded.get("srv").unwrap().access_token, "new-access");
1555    }
1556
1557    #[tokio::test]
1558    async fn authorization_header_names_the_login_command_when_refresh_fails() {
1559        let dir = tempfile::tempdir().unwrap();
1560        let store_path = dir.path().join("mcp-auth.json");
1561        let mut store = AuthStore::default();
1562        store.set(
1563            "srv",
1564            ServerAuth {
1565                token_endpoint: "http://127.0.0.1:1/token".to_string(),
1566                access_token: "expired".to_string(),
1567                refresh_token: Some("good".to_string()),
1568                expires_at: 100,
1569                ..Default::default()
1570            },
1571        );
1572        store.save(&store_path).unwrap();
1573
1574        let err = OAuthClient::new()
1575            .authorization_header("srv", &store_path, 1_000)
1576            .await
1577            .expect_err("a dead refresh must fail");
1578        assert!(err.to_string().contains("lev mcp login srv"), "got: {err}");
1579    }
1580
1581    // ─── StoredTokenRefresher ─────────────────────────────────────────────
1582
1583    use crate::transport::BearerRefresher;
1584
1585    fn refresher_at(dir: &std::path::Path) -> StoredTokenRefresher {
1586        StoredTokenRefresher {
1587            server_name: "srv".to_string(),
1588            store_path: dir.join("mcp-auth.json"),
1589            clock: || 2_000,
1590        }
1591    }
1592
1593    #[tokio::test]
1594    async fn stored_refresher_rotates_and_persists_the_token() {
1595        let server = mock_auth_server("default").await;
1596        let dir = tempfile::tempdir().unwrap();
1597        let mut store = AuthStore::default();
1598        store.set(
1599            "srv",
1600            ServerAuth {
1601                token_endpoint: format!("{}/token", server.base),
1602                refresh_token: Some("good".to_string()),
1603                expires_at: 1,
1604                ..Default::default()
1605            },
1606        );
1607        let refresher = refresher_at(dir.path());
1608        store.save(&refresher.store_path).unwrap();
1609
1610        let value = refresher.refresh().await.expect("refresh should succeed");
1611        assert_eq!(value, "Bearer new-access");
1612        // The rotation is persisted.
1613        let reloaded = AuthStore::load(&refresher.store_path).unwrap();
1614        assert_eq!(reloaded.get("srv").unwrap().access_token, "new-access");
1615    }
1616
1617    #[tokio::test]
1618    async fn stored_refresher_errors_without_stored_credentials() {
1619        let dir = tempfile::tempdir().unwrap();
1620        let refresher = refresher_at(dir.path());
1621        // Empty store → nothing to refresh.
1622        let err = refresher.refresh().await.expect_err("no creds must fail");
1623        assert!(
1624            err.to_string().contains("no stored credentials"),
1625            "got: {err}"
1626        );
1627    }
1628
1629    #[tokio::test]
1630    async fn stored_refresher_surfaces_a_refresh_failure() {
1631        let dir = tempfile::tempdir().unwrap();
1632        let mut store = AuthStore::default();
1633        store.set(
1634            "srv",
1635            ServerAuth {
1636                token_endpoint: "http://127.0.0.1:1/token".to_string(),
1637                refresh_token: Some("good".to_string()),
1638                expires_at: 1,
1639                ..Default::default()
1640            },
1641        );
1642        let refresher = refresher_at(dir.path());
1643        store.save(&refresher.store_path).unwrap();
1644        assert!(refresher.refresh().await.is_err());
1645    }
1646
1647    #[test]
1648    fn stored_refresher_new_uses_the_system_clock() {
1649        let r = StoredTokenRefresher::new("s", std::path::PathBuf::from("/tmp/x"));
1650        assert!((r.clock)() > 1_600_000_000);
1651    }
1652
1653    #[test]
1654    fn system_now_secs_advances_past_the_epoch() {
1655        assert!(system_now_secs() > 1_600_000_000);
1656    }
1657
1658    #[tokio::test]
1659    async fn authorization_header_surfaces_an_unreadable_store() {
1660        // The store path is a directory, so loading it fails.
1661        let dir = tempfile::tempdir().unwrap();
1662        assert!(
1663            OAuthClient::new()
1664                .authorization_header("srv", dir.path(), 0)
1665                .await
1666                .is_err()
1667        );
1668    }
1669
1670    #[tokio::test]
1671    async fn authorization_header_surfaces_an_unwritable_store_after_refresh() {
1672        // Refresh succeeds, but the read-only store can't persist the rotated
1673        // token.
1674        let server = mock_auth_server("default").await;
1675        let dir = tempfile::tempdir().unwrap();
1676        let store_path = dir.path().join("mcp-auth.json");
1677        let mut store = AuthStore::default();
1678        store.set(
1679            "srv",
1680            ServerAuth {
1681                token_endpoint: format!("{}/token", server.base),
1682                refresh_token: Some("good".to_string()),
1683                expires_at: 100,
1684                ..Default::default()
1685            },
1686        );
1687        store.save(&store_path).unwrap();
1688        let mut perms = std::fs::metadata(&store_path).unwrap().permissions();
1689        perms.set_readonly(true);
1690        std::fs::set_permissions(&store_path, perms).unwrap();
1691
1692        assert!(
1693            OAuthClient::new()
1694                .authorization_header("srv", &store_path, 1_000)
1695                .await
1696                .is_err()
1697        );
1698    }
1699
1700    #[tokio::test]
1701    async fn refresh_without_a_token_is_an_error() {
1702        let mut auth = ServerAuth {
1703            token_endpoint: "http://127.0.0.1:1/token".to_string(),
1704            ..Default::default()
1705        };
1706        auth.refresh_token = None;
1707        let err = OAuthClient::new()
1708            .refresh(&auth, 0)
1709            .await
1710            .expect_err("no refresh token must fail");
1711        assert!(err.to_string().contains("no refresh token"), "got: {err}");
1712    }
1713
1714    #[tokio::test]
1715    async fn refresh_surfaces_a_rejected_grant() {
1716        let server = mock_auth_server("default").await;
1717        let auth = ServerAuth {
1718            token_endpoint: format!("{}/token", server.base),
1719            refresh_token: Some("bad".to_string()),
1720            ..Default::default()
1721        };
1722        let err = OAuthClient::new()
1723            .refresh(&auth, 0)
1724            .await
1725            .expect_err("a rejected grant must fail");
1726        assert!(err.to_string().contains("refresh failed"), "got: {err}");
1727    }
1728
1729    /// A server that demands auth but does not say where its metadata lives.
1730    /// The fallback URL has to be the RFC 9728 one, which keeps the resource's
1731    /// path after the well-known segment. Serving the document *only* at the
1732    /// suffixed path is what makes this test fail if the path is dropped, which
1733    /// is exactly how GitHub's MCP server behaves.
1734    #[tokio::test]
1735    async fn a_bare_challenge_falls_back_to_the_path_suffixed_well_known_url() {
1736        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1737        let base = format!("http://{}", listener.local_addr().unwrap());
1738        let app = Router::new()
1739            .route("/mcp", post(|| async { StatusCode::UNAUTHORIZED }))
1740            .route(
1741                "/.well-known/oauth-protected-resource/mcp",
1742                get({
1743                    let base = base.clone();
1744                    move || {
1745                        let base = base.clone();
1746                        async move {
1747                            Json(serde_json::json!({
1748                                "resource": format!("{base}/mcp"),
1749                                "authorization_servers": [base],
1750                            }))
1751                        }
1752                    }
1753                }),
1754            )
1755            .route(
1756                "/.well-known/oauth-authorization-server",
1757                get({
1758                    let base = base.clone();
1759                    move || {
1760                        let base = base.clone();
1761                        async move { as_metadata(&base, "default") }
1762                    }
1763                }),
1764            )
1765            .route(
1766                "/register",
1767                post(|| async { Json(serde_json::json!({ "client_id": "c" })) }),
1768            )
1769            .route(
1770                "/token",
1771                post(|| async {
1772                    Json(serde_json::json!({"access_token": "at", "expires_in": 60}))
1773                }),
1774            );
1775        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1776            listener, app,
1777        )));
1778
1779        let auth = OAuthClient::new()
1780            .login(
1781                &format!("{base}/mcp"),
1782                &HashMap::new(),
1783                &[],
1784                auto_consent(),
1785                0,
1786                None,
1787            )
1788            .await
1789            .expect("well-known discovery should work")
1790            .authenticated()
1791            .expect("the flow should have produced credentials");
1792        assert_eq!(auth.access_token, "at");
1793    }
1794
1795    #[test]
1796    fn oauth_client_default_matches_new() {
1797        // Both build a usable client; `default` just delegates.
1798        let _ = OAuthClient::default();
1799    }
1800
1801    #[tokio::test]
1802    async fn callback_times_out_when_no_redirect_arrives() {
1803        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1804        // Nobody connects, so the tiny timeout must fire.
1805        let err = wait_for_callback(listener, "s", Duration::from_millis(100))
1806            .await
1807            .expect_err("must time out");
1808        assert!(err.to_string().contains("timed out"), "got: {err}");
1809    }
1810
1811    #[tokio::test]
1812    async fn login_sends_configured_probe_headers() {
1813        // A non-empty header map exercises the probe header loop; the server
1814        // does not require it, so login still completes.
1815        let server = mock_auth_server("default").await;
1816        let headers = HashMap::from([("X-Probe".to_string(), "1".to_string())]);
1817        OAuthClient::new()
1818            .login(
1819                &format!("{}/mcp", server.base),
1820                &headers,
1821                &[],
1822                auto_consent(),
1823                0,
1824                None,
1825            )
1826            .await
1827            .expect("login with probe headers should complete")
1828            .authenticated()
1829            .expect("the flow should have produced credentials");
1830    }
1831
1832    #[tokio::test]
1833    async fn login_still_completes_when_the_browser_cannot_open() {
1834        // The opener reports failure (headless/SSH), but the user "pastes" the
1835        // link: the callback is still driven, so login succeeds via the
1836        // print-the-URL path.
1837        let failing_opener: BrowserOpener = Arc::new(|authorize_url: &str| {
1838            drive_callback(authorize_url, None);
1839            false
1840        });
1841        let server = mock_auth_server("default").await;
1842        OAuthClient::new()
1843            .login(
1844                &format!("{}/mcp", server.base),
1845                &HashMap::new(),
1846                &[],
1847                failing_opener,
1848                0,
1849                None,
1850            )
1851            .await
1852            .expect("login should complete even without a browser")
1853            .authenticated()
1854            .expect("the flow should have produced credentials");
1855    }
1856
1857    #[tokio::test]
1858    async fn login_uses_default_scopes_when_the_server_advertises_none() {
1859        let server = mock_auth_server("no_scopes").await;
1860        OAuthClient::new()
1861            .login(
1862                &format!("{}/mcp", server.base),
1863                &HashMap::new(),
1864                &[],
1865                auto_consent(),
1866                0,
1867                None,
1868            )
1869            .await
1870            .expect("login should complete with default scopes")
1871            .authenticated()
1872            .expect("the flow should have produced credentials");
1873    }
1874
1875    #[tokio::test]
1876    async fn login_falls_back_to_the_mcp_url_when_resource_is_omitted() {
1877        let server = mock_auth_server("no_resource").await;
1878        let auth = OAuthClient::new()
1879            .login(
1880                &format!("{}/mcp", server.base),
1881                &HashMap::new(),
1882                &[],
1883                auto_consent(),
1884                0,
1885                None,
1886            )
1887            .await
1888            .expect("login should complete")
1889            .authenticated()
1890            .expect("the flow should have produced credentials");
1891        // The resource identifier defaulted to the MCP URL itself.
1892        assert_eq!(auth.resource, format!("{}/mcp", server.base));
1893    }
1894
1895    #[tokio::test]
1896    async fn login_fails_when_registration_is_rejected() {
1897        let server = mock_auth_server("register_fails").await;
1898        let err = OAuthClient::new()
1899            .login(
1900                &format!("{}/mcp", server.base),
1901                &HashMap::new(),
1902                &[],
1903                auto_consent(),
1904                0,
1905                None,
1906            )
1907            .await
1908            .expect_err("a rejected registration must fail");
1909        assert!(
1910            err.to_string().contains("registration failed"),
1911            "got: {err}"
1912        );
1913    }
1914
1915    #[tokio::test]
1916    async fn discovery_fails_when_no_metadata_document_is_reachable() {
1917        // Both the RFC 8414 and OpenID endpoints 404.
1918        let server = mock_auth_server("no_metadata").await;
1919        let err = OAuthClient::new()
1920            .login(
1921                &format!("{}/mcp", server.base),
1922                &HashMap::new(),
1923                &[],
1924                auto_consent(),
1925                0,
1926                None,
1927            )
1928            .await
1929            .expect_err("no reachable metadata must fail");
1930        assert!(
1931            err.to_string().contains("authorization server metadata"),
1932            "got: {err}"
1933        );
1934    }
1935
1936    #[tokio::test]
1937    async fn discovery_fails_when_resource_metadata_is_unavailable() {
1938        let server = mock_auth_server("discover_fails").await;
1939        let err = OAuthClient::new()
1940            .login(
1941                &format!("{}/mcp", server.base),
1942                &HashMap::new(),
1943                &[],
1944                auto_consent(),
1945                0,
1946                None,
1947            )
1948            .await
1949            .expect_err("a 500 on resource metadata must fail");
1950        assert!(err.to_string().contains("resource metadata"), "got: {err}");
1951    }
1952
1953    #[tokio::test]
1954    async fn login_fails_when_registration_returns_bad_json() {
1955        let server = mock_auth_server("register_bad_json").await;
1956        let err = OAuthClient::new()
1957            .login(
1958                &format!("{}/mcp", server.base),
1959                &HashMap::new(),
1960                &[],
1961                auto_consent(),
1962                0,
1963                None,
1964            )
1965            .await
1966            .expect_err("unparseable registration must fail");
1967        assert!(
1968            err.to_string().contains("registration response"),
1969            "got: {err}"
1970        );
1971    }
1972
1973    #[tokio::test]
1974    async fn login_fails_when_the_token_exchange_is_rejected() {
1975        let server = mock_auth_server("exchange_fails").await;
1976        let err = OAuthClient::new()
1977            .login(
1978                &format!("{}/mcp", server.base),
1979                &HashMap::new(),
1980                &[],
1981                auto_consent(),
1982                0,
1983                None,
1984            )
1985            .await
1986            .expect_err("a rejected code exchange must fail");
1987        assert!(
1988            err.to_string().contains("token exchange failed"),
1989            "got: {err}"
1990        );
1991    }
1992
1993    #[tokio::test]
1994    async fn login_fails_when_the_token_response_is_not_json() {
1995        let server = mock_auth_server("bad_token_json").await;
1996        let err = OAuthClient::new()
1997            .login(
1998                &format!("{}/mcp", server.base),
1999                &HashMap::new(),
2000                &[],
2001                auto_consent(),
2002                0,
2003                None,
2004            )
2005            .await
2006            .expect_err("an unparseable token response must fail");
2007        assert!(
2008            err.to_string().contains("parse token response"),
2009            "got: {err}"
2010        );
2011    }
2012
2013    /// A hostile MCP server pointing `resource_metadata` at somebody else's
2014    /// origin. The URL comes out of a `WWW-Authenticate` header the server
2015    /// controls entirely; fetching it without validation means connecting to
2016    /// a malicious server is enough to make Leviath issue a request to any
2017    /// URL from inside the user's network.
2018    #[tokio::test]
2019    async fn login_refuses_a_cross_origin_resource_metadata_hint() {
2020        // A server whose 401 points at a *different* origin. The target does not
2021        // need to exist: the refusal must happen before the fetch.
2022        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2023        let base = format!("http://{}", listener.local_addr().unwrap());
2024        let app = axum::Router::new().route(
2025            "/mcp",
2026            post(|| async {
2027                (
2028                    StatusCode::UNAUTHORIZED,
2029                    [(
2030                        reqwest::header::WWW_AUTHENTICATE,
2031                        "Bearer resource_metadata=\"http://169.254.169.254/latest/meta-data/\"",
2032                    )],
2033                )
2034            }),
2035        );
2036        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2037            listener, app,
2038        )));
2039
2040        let err = OAuthClient::new()
2041            .login(
2042                &format!("{base}/mcp"),
2043                &HashMap::new(),
2044                &[],
2045                auto_consent(),
2046                0,
2047                None,
2048            )
2049            .await
2050            .expect_err("a cross-origin resource_metadata hint must be refused");
2051        let msg = err.to_string();
2052        assert!(msg.contains("different origin"), "got: {msg}");
2053        assert!(msg.contains("169.254.169.254"), "got: {msg}");
2054    }
2055
2056    /// The `resource_metadata` hint is a string the remote server wrote. A value
2057    /// that is not a URL must be refused, not parsed leniently.
2058    #[tokio::test]
2059    async fn login_refuses_a_malformed_resource_metadata_hint() {
2060        let server = mock_auth_server("bad_hint").await;
2061        let err = OAuthClient::new()
2062            .login(
2063                &format!("{}/mcp", server.base),
2064                &HashMap::new(),
2065                &[],
2066                auto_consent(),
2067                0,
2068                None,
2069            )
2070            .await
2071            .expect_err("a malformed hint must be refused");
2072        assert!(
2073            err.to_string().contains("invalid resource_metadata URL"),
2074            "got: {err}"
2075        );
2076    }
2077
2078    /// RFC 8414 §3.3: the document's own `issuer` must match the issuer it was
2079    /// fetched for. Without this check, a hostile `authorization_servers[0]` in
2080    /// the resource document redirects the whole flow to an attacker's
2081    /// authorization server.
2082    #[tokio::test]
2083    async fn login_refuses_metadata_claiming_a_different_issuer() {
2084        let server = mock_auth_server("issuer_mismatch").await;
2085        let err = OAuthClient::new()
2086            .login(
2087                &format!("{}/mcp", server.base),
2088                &HashMap::new(),
2089                &[],
2090                auto_consent(),
2091                0,
2092                None,
2093            )
2094            .await
2095            .expect_err("an issuer mismatch must be refused");
2096        assert!(err.to_string().contains("RFC 8414"), "got: {err}");
2097    }
2098
2099    /// A document whose `issuer` is not a URL at all.
2100    #[tokio::test]
2101    async fn login_refuses_metadata_with_an_unparseable_issuer() {
2102        let server = mock_auth_server("as_unparseable_issuer").await;
2103        let err = OAuthClient::new()
2104            .login(
2105                &format!("{}/mcp", server.base),
2106                &HashMap::new(),
2107                &[],
2108                auto_consent(),
2109                0,
2110                None,
2111            )
2112            .await
2113            .expect_err("an unparseable issuer must be refused");
2114        assert!(err.to_string().contains("invalid issuer"), "got: {err}");
2115    }
2116
2117    /// An `authorization_endpoint` that parses fine but sits on somebody else's
2118    /// origin - where the user's browser, and the code it comes back with,
2119    /// would go.
2120    #[tokio::test]
2121    async fn login_refuses_an_endpoint_off_the_issuers_origin() {
2122        let server = mock_auth_server("foreign_endpoint").await;
2123        let err = OAuthClient::new()
2124            .login(
2125                &format!("{}/mcp", server.base),
2126                &HashMap::new(),
2127                &[],
2128                auto_consent(),
2129                0,
2130                None,
2131            )
2132            .await
2133            .expect_err("a foreign endpoint must be refused");
2134        assert!(
2135            err.to_string().contains("is not on the issuer's origin"),
2136            "got: {err}"
2137        );
2138    }
2139
2140    /// A document that omits `issuer` skips the §3.3 cross-check rather than
2141    /// failing it - there is nothing to compare against. The endpoint-origin
2142    /// check still applies, so this is a narrowing, not a bypass: the login
2143    /// proceeds normally.
2144    #[tokio::test]
2145    async fn metadata_without_an_issuer_field_still_completes() {
2146        let server = mock_auth_server("no_issuer_field").await;
2147        let auth = OAuthClient::new()
2148            .login(
2149                &format!("{}/mcp", server.base),
2150                &HashMap::new(),
2151                &[],
2152                auto_consent(),
2153                0,
2154                None,
2155            )
2156            .await
2157            .expect("an absent issuer is not itself a failure")
2158            .authenticated()
2159            .expect("the flow should have produced credentials");
2160        assert!(!auth.access_token.is_empty());
2161    }
2162
2163    /// Every URL in the discovery chain is checked, not just the first. Three
2164    /// call sites, three ways to reach a remote `http://`:
2165    ///
2166    /// 1. the resource-metadata URL itself, when the MCP server is remote;
2167    /// 2. the authorization-server metadata URLs, derived from the issuer the
2168    ///    resource document names;
2169    /// 3. the authorization/token endpoints inside that document.
2170    #[tokio::test]
2171    async fn every_step_of_discovery_refuses_remote_http() {
2172        let insecure = |err: anyhow::Error| {
2173            let msg = err.to_string();
2174            assert!(msg.contains("refusing OAuth discovery"), "got: {msg}");
2175        };
2176
2177        // (1) A remote http MCP URL. No server needed: the probe simply fails,
2178        // discovery falls back to the well-known path, and that URL is refused.
2179        insecure(
2180            OAuthClient::new()
2181                .login(
2182                    "http://mcp.example.invalid/mcp",
2183                    &HashMap::new(),
2184                    &[],
2185                    auto_consent(),
2186                    0,
2187                    None,
2188                )
2189                .await
2190                .expect_err("a remote http MCP URL must be refused"),
2191        );
2192
2193        // (2) The resource document names a remote http authorization server.
2194        let server = mock_auth_server("http_issuer").await;
2195        insecure(
2196            OAuthClient::new()
2197                .login(
2198                    &format!("{}/mcp", server.base),
2199                    &HashMap::new(),
2200                    &[],
2201                    auto_consent(),
2202                    0,
2203                    None,
2204                )
2205                .await
2206                .expect_err("a remote http issuer must be refused"),
2207        );
2208
2209        // (3) The authorization endpoint inside an otherwise-valid document.
2210        let server = mock_auth_server("http_endpoint").await;
2211        insecure(
2212            OAuthClient::new()
2213                .login(
2214                    &format!("{}/mcp", server.base),
2215                    &HashMap::new(),
2216                    &[],
2217                    auto_consent(),
2218                    0,
2219                    None,
2220                )
2221                .await
2222                .expect_err("a remote http endpoint must be refused"),
2223        );
2224    }
2225
2226    /// Plain HTTP to a *remote* host is refused: the flow carries a bearer
2227    /// token, so it would be on the wire in cleartext. The loopback exemption
2228    /// (which every mock server in this module relies on) is what keeps local
2229    /// development working.
2230    #[test]
2231    fn discovery_over_remote_http_is_refused() {
2232        let client = OAuthClient::new();
2233        let err = client
2234            .require_safe_discovery_url(&Url::parse("http://auth.example.com/x").unwrap())
2235            .expect_err("remote http must be refused");
2236        assert!(err.to_string().contains("must use https"), "got: {err}");
2237        assert!(
2238            client
2239                .require_safe_discovery_url(&Url::parse("https://auth.example.com/x").unwrap())
2240                .is_ok()
2241        );
2242    }
2243
2244    #[tokio::test]
2245    async fn login_fails_when_the_authorize_endpoint_is_malformed() {
2246        let server = mock_auth_server("bad_authorize").await;
2247        let err = OAuthClient::new()
2248            .login(
2249                &format!("{}/mcp", server.base),
2250                &HashMap::new(),
2251                &[],
2252                auto_consent(),
2253                0,
2254                None,
2255            )
2256            .await
2257            .expect_err("a bad authorize endpoint must fail");
2258        // Caught during metadata validation now, which runs before the URL is
2259        // built - so the message names the field rather than the later
2260        // build-the-authorize-URL step. Earlier is better: the endpoint never
2261        // reaches the browser opener.
2262        assert!(
2263            err.to_string().contains("authorization_endpoint"),
2264            "got: {err}"
2265        );
2266    }
2267
2268    #[tokio::test]
2269    async fn login_fails_when_no_authorization_server_is_named() {
2270        let server = mock_auth_server("no_auth_server").await;
2271        let err = OAuthClient::new()
2272            .login(
2273                &format!("{}/mcp", server.base),
2274                &HashMap::new(),
2275                &[],
2276                auto_consent(),
2277                0,
2278                None,
2279            )
2280            .await
2281            .expect_err("empty authorization_servers must fail");
2282        assert!(
2283            err.to_string().contains("no authorization server"),
2284            "got: {err}"
2285        );
2286    }
2287
2288    #[tokio::test]
2289    async fn login_fails_when_the_issuer_is_malformed() {
2290        let server = mock_auth_server("bad_issuer").await;
2291        let err = OAuthClient::new()
2292            .login(
2293                &format!("{}/mcp", server.base),
2294                &HashMap::new(),
2295                &[],
2296                auto_consent(),
2297                0,
2298                None,
2299            )
2300            .await
2301            .expect_err("a bad issuer must fail");
2302        assert!(err.to_string().contains("issuer"), "got: {err}");
2303    }
2304
2305    #[tokio::test]
2306    async fn login_fails_when_the_callback_is_forged() {
2307        // The "browser" returns a mismatched state, so wait_for_callback
2308        // rejects it and login propagates the failure.
2309        let forge: BrowserOpener = Arc::new(|authorize_url: &str| {
2310            drive_callback(authorize_url, Some("WRONG"));
2311            true
2312        });
2313        let server = mock_auth_server("default").await;
2314        let err = OAuthClient::new()
2315            .login(
2316                &format!("{}/mcp", server.base),
2317                &HashMap::new(),
2318                &[],
2319                forge,
2320                0,
2321                None,
2322            )
2323            .await
2324            .expect_err("a forged callback must fail login");
2325        assert!(err.to_string().contains("state mismatch"), "got: {err}");
2326    }
2327
2328    // ─── private HTTP helpers, driven directly ────────────────────────────
2329    //
2330    // Their network-error arms only fire on a failed request, which the
2331    // happy-path flow never produces. Calling them against a dead port or a
2332    // bad-body server exercises those arms deterministically.
2333
2334    #[tokio::test]
2335    async fn get_json_errors_on_a_dead_connection() {
2336        let err = OAuthClient::new()
2337            .get_json("http://127.0.0.1:1/x")
2338            .await
2339            .expect_err("a refused connection must fail");
2340        let _ = err;
2341    }
2342
2343    #[tokio::test]
2344    async fn get_json_errors_on_a_non_success_status() {
2345        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2346        let base = format!("http://{}", listener.local_addr().unwrap());
2347        let app = Router::new().route("/x", get(|| async { StatusCode::NOT_FOUND }));
2348        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2349            listener, app,
2350        )));
2351        let err = OAuthClient::new()
2352            .get_json(&format!("{base}/x"))
2353            .await
2354            .expect_err("404 must fail");
2355        assert!(err.to_string().contains("404"), "got: {err}");
2356    }
2357
2358    #[tokio::test]
2359    async fn get_json_errors_on_an_unparseable_body() {
2360        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2361        let base = format!("http://{}", listener.local_addr().unwrap());
2362        let app = Router::new().route("/x", get(|| async { "not json" }));
2363        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2364            listener, app,
2365        )));
2366        assert!(
2367            OAuthClient::new()
2368                .get_json(&format!("{base}/x"))
2369                .await
2370                .is_err()
2371        );
2372    }
2373
2374    #[tokio::test]
2375    async fn post_form_errors_on_a_dead_connection() {
2376        assert!(
2377            OAuthClient::new()
2378                .post_form("http://127.0.0.1:1/token", &[("a", "b")])
2379                .await
2380                .is_err()
2381        );
2382    }
2383
2384    #[tokio::test]
2385    async fn register_errors_on_a_dead_connection() {
2386        let meta: AuthServerMetadata = serde_json::from_value(serde_json::json!({
2387            "issuer": "https://x",
2388            "authorization_endpoint": "https://x/a",
2389            "token_endpoint": "https://x/t",
2390            "registration_endpoint": "http://127.0.0.1:1/register",
2391        }))
2392        .unwrap();
2393        let err = OAuthClient::new()
2394            .register(&meta, "http://127.0.0.1:5000/callback")
2395            .await
2396            .expect_err("a dead registration endpoint must fail");
2397        assert!(
2398            err.to_string().contains("registration request failed"),
2399            "got: {err}"
2400        );
2401    }
2402
2403    #[tokio::test]
2404    async fn probe_of_a_dead_server_is_unreachable() {
2405        let mcp = Url::parse("http://127.0.0.1:1/mcp").unwrap();
2406        assert_eq!(
2407            OAuthClient::new()
2408                .probe_challenge(&mcp, &HashMap::new(), &[])
2409                .await
2410                .label(),
2411            "unreachable"
2412        );
2413    }
2414
2415    /// The case this whole path exists for. A server holding its own API token
2416    /// answers the probe normally, so there is no OAuth flow to run, and the
2417    /// old code went looking for a discovery document such a server does not
2418    /// publish.
2419    #[tokio::test]
2420    async fn probe_with_headers_the_server_accepts_is_satisfied() {
2421        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2422        let base = format!("http://{}", listener.local_addr().unwrap());
2423        let app = Router::new().route(
2424            "/mcp",
2425            post(|headers: axum::http::HeaderMap| async move {
2426                match headers.get(reqwest::header::AUTHORIZATION) {
2427                    Some(_) => StatusCode::OK,
2428                    None => StatusCode::UNAUTHORIZED,
2429                }
2430            }),
2431        );
2432        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2433            listener, app,
2434        )));
2435
2436        let mcp = Url::parse(&format!("{base}/mcp")).unwrap();
2437        let client = OAuthClient::new();
2438
2439        // Same server, same endpoint: the headers are the only difference.
2440        assert_eq!(
2441            client
2442                .probe_challenge(&mcp, &HashMap::new(), &[])
2443                .await
2444                .label(),
2445            "challenge",
2446            "no credentials, so the server should ask for some"
2447        );
2448        let headers = HashMap::from([(
2449            "Authorization".to_string(),
2450            "Bearer configured-token".to_string(),
2451        )]);
2452        assert_eq!(
2453            client.probe_challenge(&mcp, &headers, &[]).await.label(),
2454            "satisfied",
2455            "the configured header should be enough"
2456        );
2457    }
2458
2459    /// The probe has to send what the transport will send, `${VAR}` expanded
2460    /// and all. A server that checks the *value* of the credential rejects a
2461    /// literal `${TOKEN}`, and a correctly configured client was being sent
2462    /// into an OAuth flow on the strength of that rejection.
2463    #[tokio::test]
2464    async fn the_probe_expands_variables_the_way_the_transport_will() {
2465        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2466        let base = format!("http://{}", listener.local_addr().unwrap());
2467        let app = Router::new().route(
2468            "/mcp",
2469            post(|headers: axum::http::HeaderMap| async move {
2470                // Only the expanded value is accepted.
2471                match headers
2472                    .get(reqwest::header::AUTHORIZATION)
2473                    .map(|v| v.as_bytes())
2474                {
2475                    Some(b"Bearer the-real-value") => StatusCode::OK,
2476                    _ => StatusCode::UNAUTHORIZED,
2477                }
2478            }),
2479        );
2480        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2481            listener, app,
2482        )));
2483
2484        let mcp = Url::parse(&format!("{base}/mcp")).unwrap();
2485        let headers = HashMap::from([(
2486            "Authorization".to_string(),
2487            "Bearer ${LEVIATH_PROBE_TEST_TOKEN}".to_string(),
2488        )]);
2489        let client = OAuthClient::new();
2490        let allow = ["LEVIATH_PROBE_TEST_TOKEN".to_string()];
2491
2492        let (allowed, refused) = temp_env::async_with_vars(
2493            [("LEVIATH_PROBE_TEST_TOKEN", Some("the-real-value"))],
2494            async {
2495                let allowed = client.probe_challenge(&mcp, &headers, &allow).await;
2496                // The same header with no allowlist: the value is refused, so
2497                // the server sees a credential it does not recognise and OAuth
2498                // genuinely is the right answer.
2499                let refused = client.probe_challenge(&mcp, &headers, &[]).await;
2500                (allowed, refused)
2501            },
2502        )
2503        .await;
2504
2505        assert_eq!(
2506            allowed.label(),
2507            "satisfied",
2508            "an allowed variable expands, so the server accepts the request"
2509        );
2510        assert_eq!(
2511            refused.label(),
2512            "challenge",
2513            "a refused variable leaves a credential the server rejects"
2514        );
2515    }
2516
2517    /// End to end through `login`: a server the headers already satisfy must
2518    /// come back as "nothing to do" rather than being dragged into discovery.
2519    /// The endpoint below serves no metadata at all, so a login that tries to
2520    /// discover fails loudly here.
2521    #[tokio::test]
2522    async fn login_is_not_required_when_the_configured_headers_suffice() {
2523        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2524        let base = format!("http://{}", listener.local_addr().unwrap());
2525        let app = Router::new().route("/mcp", post(|| async { StatusCode::OK }));
2526        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
2527            listener, app,
2528        )));
2529
2530        let outcome = OAuthClient::new()
2531            .login(
2532                &format!("{base}/mcp"),
2533                &HashMap::from([("Authorization".to_string(), "Bearer tok".to_string())]),
2534                &[],
2535                auto_consent(),
2536                0,
2537                None,
2538            )
2539            .await
2540            .expect("a server that wants nothing is not a login failure");
2541        assert!(
2542            outcome.authenticated().is_none(),
2543            "there are no credentials to store"
2544        );
2545    }
2546
2547    #[tokio::test]
2548    async fn login_fails_when_resource_metadata_is_not_an_object() {
2549        let server = mock_auth_server("resource_not_object").await;
2550        let err = OAuthClient::new()
2551            .login(
2552                &format!("{}/mcp", server.base),
2553                &HashMap::new(),
2554                &[],
2555                auto_consent(),
2556                0,
2557                None,
2558            )
2559            .await
2560            .expect_err("malformed resource metadata must fail");
2561        assert!(
2562            err.to_string().contains("parse resource metadata"),
2563            "got: {err}"
2564        );
2565    }
2566
2567    #[tokio::test]
2568    async fn login_fails_when_the_token_lacks_an_access_token() {
2569        let server = mock_auth_server("token_no_access").await;
2570        let err = OAuthClient::new()
2571            .login(
2572                &format!("{}/mcp", server.base),
2573                &HashMap::new(),
2574                &[],
2575                auto_consent(),
2576                0,
2577                None,
2578            )
2579            .await
2580            .expect_err("a token without access_token must fail");
2581        assert!(
2582            err.to_string().contains("parse token response"),
2583            "got: {err}"
2584        );
2585    }
2586
2587    #[tokio::test]
2588    async fn refresh_fails_when_the_token_lacks_an_access_token() {
2589        let server = mock_auth_server("token_no_access").await;
2590        let auth = ServerAuth {
2591            token_endpoint: format!("{}/token", server.base),
2592            refresh_token: Some("good".to_string()),
2593            ..Default::default()
2594        };
2595        let err = OAuthClient::new()
2596            .refresh(&auth, 0)
2597            .await
2598            .expect_err("a malformed refresh token response must fail");
2599        assert!(
2600            err.to_string().contains("parse token response"),
2601            "got: {err}"
2602        );
2603    }
2604
2605    #[tokio::test]
2606    async fn login_rejects_a_bad_mcp_url() {
2607        let err = OAuthClient::new()
2608            .login("not a url", &HashMap::new(), &[], auto_consent(), 0, None)
2609            .await
2610            .expect_err("bad url must fail");
2611        assert!(
2612            err.to_string().contains("Invalid MCP server url"),
2613            "got: {err}"
2614        );
2615    }
2616}