1pub(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
31pub type BrowserOpener = std::sync::Arc<dyn Fn(&str) -> bool + Send + Sync>;
40
41const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
43
44const DEFAULT_SCOPES: &str = "openid profile email";
46
47#[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#[derive(Debug, Deserialize)]
61struct RegistrationResponse {
62 client_id: String,
63}
64
65enum Probe {
68 Challenge(Option<String>),
71 Satisfied,
74 Unreachable,
77}
78
79#[cfg(test)]
80impl Probe {
81 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#[derive(Debug)]
94pub enum LoginOutcome {
95 Authenticated(Box<ServerAuth>),
100 NotRequired,
103}
104
105impl LoginOutcome {
106 pub fn authenticated(self) -> Option<ServerAuth> {
111 match self {
112 Self::Authenticated(auth) => Some(*auth),
113 Self::NotRequired => None,
114 }
115 }
116}
117
118pub 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 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 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 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 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 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 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 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, ¶ms)
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 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 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 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 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 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 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 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 async fn fetch_auth_server_metadata(&self, issuer: &str) -> anyhow::Result<AuthServerMetadata> {
404 let mut last_err = None;
405 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 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 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 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 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 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 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 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 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, ¶ms)
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 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 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
632pub struct StoredTokenRefresher {
638 server_name: String,
639 store_path: std::path::PathBuf,
640 clock: fn() -> u64,
642}
643
644impl StoredTokenRefresher {
645 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
655fn 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
681fn 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 .append_pair("resource", resource);
705 url
706}
707
708fn 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
729fn 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
738async 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 let (stream, _) = listener
752 .accept()
753 .await
754 .expect("accepting on a bound loopback listener cannot fail");
755 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
771async 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 (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 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
834fn 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
842fn 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
850async 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 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 assert_eq!(params["resource"], "https://mcp.example.com/mcp");
897 }
898
899 #[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 #[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 assert_eq!(request_target("GET"), None);
932 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 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 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1010 use tokio::net::TcpStream;
1011
1012 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 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 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 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 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 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 let hint = if variant == "bad_hint" {
1155 "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 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 "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 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 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 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 "foreign_endpoint" => "https://evil.example.com/authorize".to_string(),
1287 "http_endpoint" => "http://evil.example.com/authorize".to_string(),
1290 _ => format!("{base}/authorize"),
1291 };
1292 let issuer = match variant {
1293 "issuer_mismatch" => "https://someone-else.example.com".to_string(),
1296 "as_unparseable_issuer" => "not a url".to_string(),
1300 "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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[tokio::test]
2019 async fn login_refuses_a_cross_origin_resource_metadata_hint() {
2020 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 #[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 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 #[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 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 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 #[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}