1use std::fmt;
44use std::sync::Arc;
45use std::time::{Duration, Instant};
46
47use async_trait::async_trait;
48use tokio::sync::RwLock;
49
50use super::oauth_authcode::discover_oauth_authorization;
51
52#[async_trait]
73pub trait TokenProvider: Send + Sync + 'static {
74 async fn get_token(&self) -> Result<String, OAuthClientError>;
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct OAuthBearerChallenge {
89 pub error: Option<String>,
91 pub scopes: Vec<String>,
93 pub resource_metadata: Option<String>,
95 pub error_description: Option<String>,
97}
98
99impl OAuthBearerChallenge {
100 pub fn from_www_authenticate(header: &str) -> Option<Self> {
105 let mut in_bearer_challenge = false;
106 let mut found_bearer_challenge = false;
107 let mut error = None;
108 let mut scope = None;
109 let mut resource_metadata = None;
110 let mut error_description = None;
111
112 for segment in split_quoted_commas(header) {
113 let segment = segment.trim();
114 let (parameter, starts_challenge) = if let Some((candidate_scheme, remainder)) =
115 segment.split_once(char::is_whitespace)
116 {
117 if !candidate_scheme.contains('=') {
118 if found_bearer_challenge {
119 break;
120 }
121 in_bearer_challenge = candidate_scheme.eq_ignore_ascii_case("Bearer");
122 found_bearer_challenge = in_bearer_challenge;
123 (remainder.trim(), true)
124 } else {
125 (segment, false)
126 }
127 } else {
128 (segment, false)
129 };
130
131 if starts_challenge && !in_bearer_challenge {
132 continue;
133 }
134 if !in_bearer_challenge {
135 continue;
136 }
137
138 let Some((name, value)) = parameter.split_once('=') else {
139 continue;
140 };
141 let value = unquote_auth_param(value.trim())?;
142 match name.trim() {
143 name if name.eq_ignore_ascii_case("error") => error = Some(value),
144 name if name.eq_ignore_ascii_case("scope") => scope = Some(value),
145 name if name.eq_ignore_ascii_case("resource_metadata") => {
146 resource_metadata = Some(value)
147 }
148 name if name.eq_ignore_ascii_case("error_description") => {
149 error_description = Some(value)
150 }
151 _ => {}
152 }
153 }
154
155 found_bearer_challenge.then(|| Self {
156 error,
157 scopes: unique_scopes(
158 scope
159 .iter()
160 .flat_map(|value| value.split_ascii_whitespace()),
161 ),
162 resource_metadata,
163 error_description,
164 })
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct OAuthScopeChallenge {
175 pub required_scopes: Vec<String>,
177 pub resource_metadata: Option<String>,
179 pub error_description: Option<String>,
181}
182
183impl OAuthScopeChallenge {
184 pub fn from_www_authenticate(header: &str) -> Option<Self> {
190 let challenge = OAuthBearerChallenge::from_www_authenticate(header)?;
191 if challenge.error.as_deref() != Some("insufficient_scope") {
192 return None;
193 }
194 if challenge.scopes.is_empty() {
195 return None;
196 }
197
198 Some(Self {
199 required_scopes: challenge.scopes,
200 resource_metadata: challenge.resource_metadata,
201 error_description: challenge.error_description,
202 })
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
208#[serde(rename_all = "snake_case")]
209#[non_exhaustive]
210pub enum OAuthTokenEndpointAuthMethod {
211 None,
213 ClientSecretBasic,
215 ClientSecretPost,
217 PrivateKeyJwt,
219}
220
221impl OAuthTokenEndpointAuthMethod {
222 pub(crate) fn select(
223 advertised: &[String],
224 has_client_secret: bool,
225 ) -> Result<Self, OAuthClientError> {
226 if advertised.is_empty() {
227 return Ok(if has_client_secret {
228 Self::ClientSecretBasic
229 } else {
230 Self::None
231 });
232 }
233
234 if has_client_secret
235 && advertised
236 .iter()
237 .any(|method| method == "client_secret_basic")
238 {
239 return Ok(Self::ClientSecretBasic);
240 }
241 if has_client_secret
242 && advertised
243 .iter()
244 .any(|method| method == "client_secret_post")
245 {
246 return Ok(Self::ClientSecretPost);
247 }
248 if advertised.iter().any(|method| method == "none") {
249 return Ok(Self::None);
250 }
251
252 Err(OAuthClientError::BuildError(format!(
253 "authorization server supports no compatible token endpoint authentication method: {}",
254 advertised.join(", ")
255 )))
256 }
257
258 pub(crate) fn select_with_private_key(
259 advertised: &[String],
260 has_client_secret: bool,
261 has_private_key_signer: bool,
262 ) -> Result<Self, OAuthClientError> {
263 if has_private_key_signer && advertised.iter().any(|method| method == "private_key_jwt") {
264 return Ok(Self::PrivateKeyJwt);
265 }
266 Self::select(advertised, has_client_secret)
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct OAuthScopeEscalationRequest {
273 pub resource: String,
275 pub operation: String,
277 pub challenge: OAuthScopeChallenge,
279 pub previous_scopes: Vec<String>,
281 pub requested_scopes: Vec<String>,
283 pub attempt: usize,
285}
286
287#[async_trait]
295pub trait OAuthScopeEscalationHandler: Send + Sync + 'static {
296 async fn reauthorize(
298 &self,
299 request: OAuthScopeEscalationRequest,
300 ) -> Result<(), OAuthClientError>;
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct OAuthScopeEscalationConfig {
306 initial_scopes: Vec<String>,
307 max_attempts: usize,
308}
309
310impl Default for OAuthScopeEscalationConfig {
311 fn default() -> Self {
312 Self {
313 initial_scopes: Vec::new(),
314 max_attempts: 2,
315 }
316 }
317}
318
319impl OAuthScopeEscalationConfig {
320 pub fn new(scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
326 Self {
327 initial_scopes: unique_scopes(scopes.into_iter().map(Into::into).flat_map(
328 |scope: String| {
329 scope
330 .split_ascii_whitespace()
331 .map(str::to_string)
332 .collect::<Vec<_>>()
333 },
334 )),
335 ..Self::default()
336 }
337 }
338
339 pub fn max_attempts(mut self, max_attempts: usize) -> Self {
343 self.max_attempts = max_attempts;
344 self
345 }
346
347 pub fn initial_scopes(&self) -> &[String] {
349 &self.initial_scopes
350 }
351
352 pub fn maximum_attempts(&self) -> usize {
354 self.max_attempts
355 }
356}
357
358fn unique_scopes(scopes: impl IntoIterator<Item = impl AsRef<str>>) -> Vec<String> {
359 let mut unique = Vec::new();
360 for scope in scopes {
361 let scope = scope.as_ref();
362 if !scope.is_empty() && !unique.iter().any(|existing| existing == scope) {
363 unique.push(scope.to_string());
364 }
365 }
366 unique
367}
368
369fn split_quoted_commas(header: &str) -> Vec<&str> {
370 let mut segments = Vec::new();
371 let mut start = 0;
372 let mut quoted = false;
373 let mut escaped = false;
374 for (index, character) in header.char_indices() {
375 if escaped {
376 escaped = false;
377 continue;
378 }
379 match character {
380 '\\' if quoted => escaped = true,
381 '"' => quoted = !quoted,
382 ',' if !quoted => {
383 segments.push(&header[start..index]);
384 start = index + 1;
385 }
386 _ => {}
387 }
388 }
389 segments.push(&header[start..]);
390 segments
391}
392
393fn unquote_auth_param(value: &str) -> Option<String> {
394 if !value.starts_with('"') {
395 return Some(value.to_string());
396 }
397 if !value.ends_with('"') || value.len() < 2 {
398 return None;
399 }
400
401 let mut unquoted = String::new();
402 let mut escaped = false;
403 for character in value[1..value.len() - 1].chars() {
404 if escaped {
405 unquoted.push(character);
406 escaped = false;
407 } else if character == '\\' {
408 escaped = true;
409 } else {
410 unquoted.push(character);
411 }
412 }
413 if escaped {
414 return None;
415 }
416 Some(unquoted)
417}
418
419#[derive(Debug)]
421#[non_exhaustive]
422pub enum OAuthClientError {
423 Http(String),
425 Discovery(String),
427 TokenRequest(String),
429 Registration(String),
431 CredentialStore(String),
433 TokenStore(String),
435 StateStore(String),
437 Redirect(String),
439 ScopeEscalation(String),
441 InvalidResponse(String),
443 BuildError(String),
445}
446
447impl fmt::Display for OAuthClientError {
448 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449 match self {
450 Self::Http(msg) => write!(f, "OAuth HTTP error: {}", msg),
451 Self::Discovery(msg) => write!(f, "OAuth discovery error: {}", msg),
452 Self::TokenRequest(msg) => write!(f, "OAuth token request error: {}", msg),
453 Self::Registration(msg) => write!(f, "OAuth client registration error: {}", msg),
454 Self::CredentialStore(msg) => write!(f, "OAuth credential store error: {}", msg),
455 Self::TokenStore(msg) => write!(f, "OAuth token store error: {}", msg),
456 Self::StateStore(msg) => write!(f, "OAuth state store error: {}", msg),
457 Self::Redirect(msg) => write!(f, "OAuth redirect error: {}", msg),
458 Self::ScopeEscalation(msg) => write!(f, "OAuth scope escalation error: {}", msg),
459 Self::InvalidResponse(msg) => write!(f, "OAuth invalid response: {}", msg),
460 Self::BuildError(msg) => write!(f, "OAuth builder error: {}", msg),
461 }
462 }
463}
464
465impl std::error::Error for OAuthClientError {}
466
467#[derive(Debug, Clone)]
469struct CachedToken {
470 access_token: String,
471 expires_at: Instant,
472}
473
474#[derive(Debug, serde::Deserialize)]
476struct TokenResponse {
477 access_token: String,
478 #[allow(dead_code)]
479 token_type: String,
480 expires_in: Option<u64>,
482 #[allow(dead_code)]
483 scope: Option<String>,
484}
485
486struct OAuthClientCredentialsInner {
488 client_id: String,
489 client_secret: String,
490 token_endpoint: String,
491 token_endpoint_auth_method: OAuthTokenEndpointAuthMethod,
492 resource: String,
493 scopes: Option<String>,
494 refresh_buffer: Duration,
495 client: reqwest::Client,
496 cache: RwLock<Option<CachedToken>>,
497}
498
499#[derive(Clone)]
533pub struct OAuthClientCredentials {
534 inner: Arc<OAuthClientCredentialsInner>,
535}
536
537impl fmt::Debug for OAuthClientCredentials {
538 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539 f.debug_struct("OAuthClientCredentials")
540 .field("client_id", &self.inner.client_id)
541 .field("token_endpoint", &self.inner.token_endpoint)
542 .field(
543 "token_endpoint_auth_method",
544 &self.inner.token_endpoint_auth_method,
545 )
546 .field("resource", &self.inner.resource)
547 .field("scopes", &self.inner.scopes)
548 .field("refresh_buffer", &self.inner.refresh_buffer)
549 .finish()
550 }
551}
552
553impl OAuthClientCredentials {
554 pub fn builder() -> OAuthClientCredentialsBuilder {
556 OAuthClientCredentialsBuilder::default()
557 }
558
559 pub async fn discover(
570 resource_url: &str,
571 client_id: impl Into<String>,
572 client_secret: impl Into<String>,
573 ) -> Result<Self, OAuthClientError> {
574 let client = reqwest::Client::new();
575 let discovery = discover_oauth_authorization(resource_url, None, &client).await?;
576 let metadata = discovery.authorization_servers.first().ok_or_else(|| {
577 OAuthClientError::Discovery("no authorization server discovered".into())
578 })?;
579 let auth_method = OAuthTokenEndpointAuthMethod::select(
580 &metadata.token_endpoint_auth_methods_supported,
581 true,
582 )?;
583
584 Self::builder()
585 .client_id(client_id)
586 .client_secret(client_secret)
587 .token_endpoint(metadata.token_endpoint.clone())
588 .token_endpoint_auth_method(auth_method)
589 .resource(discovery.resource)
590 .http_client(client)
591 .build()
592 .map_err(|e| OAuthClientError::Discovery(e.to_string()))
593 }
594
595 fn is_token_valid(token: &CachedToken, buffer: Duration) -> bool {
597 token
598 .expires_at
599 .checked_sub(buffer)
600 .is_some_and(|effective| Instant::now() < effective)
601 }
602
603 async fn fetch_token(&self) -> Result<CachedToken, OAuthClientError> {
605 let mut params = vec![
606 ("grant_type", "client_credentials".to_string()),
607 ("resource", self.inner.resource.clone()),
608 ];
609 if let Some(ref scopes) = self.inner.scopes {
610 params.push(("scope", scopes.clone()));
611 }
612
613 let mut request = self.inner.client.post(&self.inner.token_endpoint);
614 match self.inner.token_endpoint_auth_method {
615 OAuthTokenEndpointAuthMethod::None => {
616 params.push(("client_id", self.inner.client_id.clone()));
617 }
618 OAuthTokenEndpointAuthMethod::ClientSecretBasic => {
619 request =
620 request.basic_auth(&self.inner.client_id, Some(&self.inner.client_secret));
621 }
622 OAuthTokenEndpointAuthMethod::ClientSecretPost => {
623 params.push(("client_id", self.inner.client_id.clone()));
624 params.push(("client_secret", self.inner.client_secret.clone()));
625 }
626 OAuthTokenEndpointAuthMethod::PrivateKeyJwt => {
627 return Err(OAuthClientError::BuildError(
628 "private_key_jwt requires OAuthAuthorizationFlow with a client assertion signer"
629 .to_string(),
630 ));
631 }
632 }
633
634 let response = request
635 .form(¶ms)
636 .send()
637 .await
638 .map_err(|e| OAuthClientError::TokenRequest(e.to_string()))?;
639
640 if !response.status().is_success() {
641 let status = response.status();
642 let body = response.text().await.unwrap_or_default();
643 return Err(OAuthClientError::TokenRequest(format!(
644 "HTTP {}: {}",
645 status, body
646 )));
647 }
648
649 let token_response: TokenResponse = response
650 .json()
651 .await
652 .map_err(|e| OAuthClientError::InvalidResponse(e.to_string()))?;
653
654 let expires_in = Duration::from_secs(token_response.expires_in.unwrap_or(3600));
656 let expires_at = Instant::now() + expires_in;
657
658 Ok(CachedToken {
659 access_token: token_response.access_token,
660 expires_at,
661 })
662 }
663}
664
665#[async_trait]
666impl TokenProvider for OAuthClientCredentials {
667 async fn get_token(&self) -> Result<String, OAuthClientError> {
668 {
670 let cache = self.inner.cache.read().await;
671 if let Some(ref token) = *cache
672 && Self::is_token_valid(token, self.inner.refresh_buffer)
673 {
674 return Ok(token.access_token.clone());
675 }
676 }
677
678 let mut cache = self.inner.cache.write().await;
680
681 if let Some(ref token) = *cache
683 && Self::is_token_valid(token, self.inner.refresh_buffer)
684 {
685 return Ok(token.access_token.clone());
686 }
687
688 let token = self.fetch_token().await?;
689 let access_token = token.access_token.clone();
690 *cache = Some(token);
691
692 Ok(access_token)
693 }
694}
695
696#[derive(Default)]
721pub struct OAuthClientCredentialsBuilder {
722 client_id: Option<String>,
723 client_secret: Option<String>,
724 token_endpoint: Option<String>,
725 token_endpoint_auth_method: Option<OAuthTokenEndpointAuthMethod>,
726 resource: Option<String>,
727 scopes: Option<String>,
728 refresh_buffer: Option<Duration>,
729 client: Option<reqwest::Client>,
730}
731
732impl OAuthClientCredentialsBuilder {
733 pub fn client_id(mut self, client_id: impl Into<String>) -> Self {
735 self.client_id = Some(client_id.into());
736 self
737 }
738
739 pub fn client_secret(mut self, client_secret: impl Into<String>) -> Self {
741 self.client_secret = Some(client_secret.into());
742 self
743 }
744
745 pub fn token_endpoint(mut self, url: impl Into<String>) -> Self {
747 self.token_endpoint = Some(url.into());
748 self
749 }
750
751 pub fn token_endpoint_auth_method(mut self, method: OAuthTokenEndpointAuthMethod) -> Self {
756 self.token_endpoint_auth_method = Some(method);
757 self
758 }
759
760 pub fn resource(mut self, resource: impl Into<String>) -> Self {
765 self.resource = Some(resource.into());
766 self
767 }
768
769 pub fn scopes(mut self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
774 let scope_str: Vec<String> = scopes.into_iter().map(|s| s.into()).collect();
775 if !scope_str.is_empty() {
776 self.scopes = Some(scope_str.join(" "));
777 }
778 self
779 }
780
781 pub fn refresh_buffer(mut self, duration: Duration) -> Self {
786 self.refresh_buffer = Some(duration);
787 self
788 }
789
790 pub fn http_client(mut self, client: reqwest::Client) -> Self {
794 self.client = Some(client);
795 self
796 }
797
798 pub fn build(self) -> Result<OAuthClientCredentials, OAuthClientError> {
805 let client_id = self
806 .client_id
807 .ok_or_else(|| OAuthClientError::BuildError("client_id is required".into()))?;
808 let client_secret = self
809 .client_secret
810 .ok_or_else(|| OAuthClientError::BuildError("client_secret is required".into()))?;
811 let token_endpoint = self
812 .token_endpoint
813 .ok_or_else(|| OAuthClientError::BuildError("token_endpoint is required".into()))?;
814 let resource = self
815 .resource
816 .ok_or_else(|| OAuthClientError::BuildError("resource is required".into()))?;
817
818 let inner = OAuthClientCredentialsInner {
819 client_id,
820 client_secret,
821 token_endpoint,
822 token_endpoint_auth_method: self
823 .token_endpoint_auth_method
824 .unwrap_or(OAuthTokenEndpointAuthMethod::ClientSecretBasic),
825 resource,
826 scopes: self.scopes,
827 refresh_buffer: self.refresh_buffer.unwrap_or(Duration::from_secs(30)),
828 client: self.client.unwrap_or_default(),
829 cache: RwLock::new(None),
830 };
831
832 Ok(OAuthClientCredentials {
833 inner: Arc::new(inner),
834 })
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use std::sync::atomic::{AtomicUsize, Ordering};
842
843 #[test]
844 fn test_builder_missing_client_id() {
845 let err = OAuthClientCredentials::builder()
846 .client_secret("secret")
847 .token_endpoint("https://auth.example.com/token")
848 .build()
849 .unwrap_err();
850 assert!(err.to_string().contains("client_id"));
851 }
852
853 #[test]
854 fn test_builder_missing_client_secret() {
855 let err = OAuthClientCredentials::builder()
856 .client_id("id")
857 .token_endpoint("https://auth.example.com/token")
858 .build()
859 .unwrap_err();
860 assert!(err.to_string().contains("client_secret"));
861 }
862
863 #[test]
864 fn test_builder_missing_token_endpoint() {
865 let err = OAuthClientCredentials::builder()
866 .client_id("id")
867 .client_secret("secret")
868 .build()
869 .unwrap_err();
870 assert!(err.to_string().contains("token_endpoint"));
871 }
872
873 #[test]
874 fn test_builder_requires_resource_binding() {
875 let err = OAuthClientCredentials::builder()
876 .client_id("id")
877 .client_secret("secret")
878 .token_endpoint("https://auth.example.com/token")
879 .build()
880 .unwrap_err();
881 assert!(err.to_string().contains("resource"));
882 }
883
884 #[test]
885 fn test_builder_success() {
886 let provider = OAuthClientCredentials::builder()
887 .client_id("my-client")
888 .client_secret("my-secret")
889 .token_endpoint("https://auth.example.com/token")
890 .resource("https://mcp.example.com")
891 .build()
892 .unwrap();
893
894 assert_eq!(provider.inner.client_id, "my-client");
895 assert_eq!(
896 provider.inner.token_endpoint,
897 "https://auth.example.com/token"
898 );
899 assert!(provider.inner.scopes.is_none());
900 assert_eq!(provider.inner.refresh_buffer, Duration::from_secs(30));
901 }
902
903 #[test]
904 fn test_builder_with_scopes() {
905 let provider = OAuthClientCredentials::builder()
906 .client_id("id")
907 .client_secret("secret")
908 .token_endpoint("https://auth.example.com/token")
909 .resource("https://mcp.example.com")
910 .scopes(["mcp:tools", "mcp:resources"])
911 .build()
912 .unwrap();
913
914 assert_eq!(
915 provider.inner.scopes.as_deref(),
916 Some("mcp:tools mcp:resources")
917 );
918 }
919
920 #[test]
921 fn test_builder_with_refresh_buffer() {
922 let provider = OAuthClientCredentials::builder()
923 .client_id("id")
924 .client_secret("secret")
925 .token_endpoint("https://auth.example.com/token")
926 .resource("https://mcp.example.com")
927 .refresh_buffer(Duration::from_secs(60))
928 .build()
929 .unwrap();
930
931 assert_eq!(provider.inner.refresh_buffer, Duration::from_secs(60));
932 }
933
934 #[test]
935 fn test_debug_impl() {
936 let provider = OAuthClientCredentials::builder()
937 .client_id("my-client")
938 .client_secret("secret")
939 .token_endpoint("https://auth.example.com/token")
940 .resource("https://mcp.example.com")
941 .build()
942 .unwrap();
943
944 let debug = format!("{:?}", provider);
945 assert!(debug.contains("my-client"));
946 assert!(debug.contains("auth.example.com"));
947 assert!(!debug.contains("secret"));
949 }
950
951 #[test]
952 fn test_token_validity() {
953 let valid_token = CachedToken {
954 access_token: "valid".into(),
955 expires_at: Instant::now() + Duration::from_secs(300),
956 };
957 assert!(OAuthClientCredentials::is_token_valid(
958 &valid_token,
959 Duration::from_secs(30)
960 ));
961
962 let expiring_soon = CachedToken {
963 access_token: "expiring".into(),
964 expires_at: Instant::now() + Duration::from_secs(10),
965 };
966 assert!(!OAuthClientCredentials::is_token_valid(
968 &expiring_soon,
969 Duration::from_secs(30)
970 ));
971
972 let expired = CachedToken {
973 access_token: "expired".into(),
974 expires_at: Instant::now() - Duration::from_secs(10),
975 };
976 assert!(!OAuthClientCredentials::is_token_valid(
977 &expired,
978 Duration::from_secs(30)
979 ));
980 }
981
982 #[test]
983 fn test_error_display() {
984 let err = OAuthClientError::Discovery("not found".into());
985 assert_eq!(err.to_string(), "OAuth discovery error: not found");
986
987 let err = OAuthClientError::TokenRequest("timeout".into());
988 assert_eq!(err.to_string(), "OAuth token request error: timeout");
989
990 let err = OAuthClientError::Registration("rejected".into());
991 assert_eq!(err.to_string(), "OAuth client registration error: rejected");
992
993 let err = OAuthClientError::CredentialStore("unavailable".into());
994 assert_eq!(err.to_string(), "OAuth credential store error: unavailable");
995
996 let err = OAuthClientError::ScopeEscalation("denied".into());
997 assert_eq!(err.to_string(), "OAuth scope escalation error: denied");
998
999 let err = OAuthClientError::InvalidResponse("bad json".into());
1000 assert_eq!(err.to_string(), "OAuth invalid response: bad json");
1001
1002 let err = OAuthClientError::BuildError("missing field".into());
1003 assert_eq!(err.to_string(), "OAuth builder error: missing field");
1004 }
1005
1006 #[test]
1007 fn parses_insufficient_scope_challenge() {
1008 let challenge = OAuthScopeChallenge::from_www_authenticate(
1009 r#"Bearer error="insufficient_scope", scope="files.read files.write files.read", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", error_description="Need files, including \"shared\"""#,
1010 )
1011 .unwrap();
1012
1013 assert_eq!(challenge.required_scopes, vec!["files.read", "files.write"]);
1014 assert_eq!(
1015 challenge.resource_metadata.as_deref(),
1016 Some("https://mcp.example.com/.well-known/oauth-protected-resource")
1017 );
1018 assert_eq!(
1019 challenge.error_description.as_deref(),
1020 Some(r#"Need files, including "shared""#)
1021 );
1022 }
1023
1024 #[test]
1025 fn parses_initial_bearer_discovery_challenge() {
1026 let challenge = OAuthBearerChallenge::from_www_authenticate(
1027 r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="tools.read resources.read""#,
1028 )
1029 .unwrap();
1030
1031 assert_eq!(challenge.error, None);
1032 assert_eq!(challenge.scopes, vec!["tools.read", "resources.read"]);
1033 assert_eq!(
1034 challenge.resource_metadata.as_deref(),
1035 Some("https://mcp.example.com/.well-known/oauth-protected-resource/mcp")
1036 );
1037 }
1038
1039 #[test]
1040 fn token_auth_selection_follows_metadata_and_credentials() {
1041 assert_eq!(
1042 OAuthTokenEndpointAuthMethod::select(&[], true).unwrap(),
1043 OAuthTokenEndpointAuthMethod::ClientSecretBasic
1044 );
1045 assert_eq!(
1046 OAuthTokenEndpointAuthMethod::select(&["none".into()], false).unwrap(),
1047 OAuthTokenEndpointAuthMethod::None
1048 );
1049 assert_eq!(
1050 OAuthTokenEndpointAuthMethod::select(
1051 &["client_secret_post".into(), "client_secret_basic".into()],
1052 true,
1053 )
1054 .unwrap(),
1055 OAuthTokenEndpointAuthMethod::ClientSecretBasic
1056 );
1057 assert!(OAuthTokenEndpointAuthMethod::select(&["private_key_jwt".into()], true).is_err());
1058 }
1059
1060 #[test]
1061 fn selects_bearer_from_multiple_authentication_challenges() {
1062 let challenge = OAuthScopeChallenge::from_www_authenticate(
1063 r#"Basic realm="legacy", Bearer realm="mcp", error="insufficient_scope", scope="tools.call""#,
1064 )
1065 .unwrap();
1066
1067 assert_eq!(challenge.required_scopes, vec!["tools.call"]);
1068 }
1069
1070 #[test]
1071 fn ignores_non_scope_authentication_challenges() {
1072 assert!(
1073 OAuthScopeChallenge::from_www_authenticate(
1074 r#"Bearer error="invalid_token", scope="tools.call""#
1075 )
1076 .is_none()
1077 );
1078 assert!(
1079 OAuthScopeChallenge::from_www_authenticate(
1080 r#"Bearer error="insufficient_scope", scope="""#
1081 )
1082 .is_none()
1083 );
1084 assert!(OAuthScopeChallenge::from_www_authenticate(r#"Basic realm="mcp""#).is_none());
1085 }
1086
1087 #[test]
1088 fn scope_escalation_config_normalizes_scopes() {
1089 let config =
1090 OAuthScopeEscalationConfig::new(["openid profile", "profile", "", "tools.call"])
1091 .max_attempts(3);
1092
1093 assert_eq!(
1094 config.initial_scopes(),
1095 &["openid", "profile", "tools.call"]
1096 );
1097 assert_eq!(config.maximum_attempts(), 3);
1098 }
1099
1100 #[tokio::test]
1101 async fn test_caching_returns_same_token() {
1102 let provider = OAuthClientCredentials::builder()
1104 .client_id("id")
1105 .client_secret("secret")
1106 .token_endpoint("https://auth.example.com/token")
1107 .resource("https://mcp.example.com")
1108 .build()
1109 .unwrap();
1110
1111 {
1113 let mut cache = provider.inner.cache.write().await;
1114 *cache = Some(CachedToken {
1115 access_token: "cached-token-123".into(),
1116 expires_at: Instant::now() + Duration::from_secs(300),
1117 });
1118 }
1119
1120 let token = provider.get_token().await.unwrap();
1121 assert_eq!(token, "cached-token-123");
1122
1123 let token2 = provider.get_token().await.unwrap();
1125 assert_eq!(token2, "cached-token-123");
1126 }
1127
1128 #[tokio::test]
1129 async fn test_expired_token_triggers_refresh_attempt() {
1130 let provider = OAuthClientCredentials::builder()
1131 .client_id("id")
1132 .client_secret("secret")
1133 .token_endpoint("http://127.0.0.1:1/nonexistent")
1134 .resource("https://mcp.example.com")
1135 .build()
1136 .unwrap();
1137
1138 {
1140 let mut cache = provider.inner.cache.write().await;
1141 *cache = Some(CachedToken {
1142 access_token: "expired-token".into(),
1143 expires_at: Instant::now() - Duration::from_secs(60),
1144 });
1145 }
1146
1147 let err = provider.get_token().await.unwrap_err();
1149 assert!(matches!(err, OAuthClientError::TokenRequest(_)));
1150 }
1151
1152 #[tokio::test]
1153 async fn client_credentials_posts_resource_and_selected_auth_method() {
1154 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1155
1156 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1157 let endpoint = format!("http://{}/token", listener.local_addr().unwrap());
1158 let server = tokio::spawn(async move {
1159 let (mut stream, _) = listener.accept().await.unwrap();
1160 let mut bytes = Vec::new();
1161 let header_end = loop {
1162 let mut chunk = [0_u8; 1024];
1163 let read = stream.read(&mut chunk).await.unwrap();
1164 assert!(read > 0);
1165 bytes.extend_from_slice(&chunk[..read]);
1166 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
1167 break index + 4;
1168 }
1169 };
1170 let headers = String::from_utf8_lossy(&bytes[..header_end]);
1171 let content_length = headers
1172 .lines()
1173 .find_map(|line| {
1174 let (name, value) = line.split_once(':')?;
1175 name.eq_ignore_ascii_case("content-length")
1176 .then(|| value.trim().parse::<usize>().unwrap())
1177 })
1178 .unwrap();
1179 while bytes.len() < header_end + content_length {
1180 let mut chunk = [0_u8; 1024];
1181 let read = stream.read(&mut chunk).await.unwrap();
1182 bytes.extend_from_slice(&chunk[..read]);
1183 }
1184 let body = String::from_utf8_lossy(&bytes[header_end..header_end + content_length])
1185 .to_string();
1186 let response_body =
1187 r#"{"access_token":"service-token","token_type":"Bearer","expires_in":3600}"#;
1188 let response = format!(
1189 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{response_body}",
1190 response_body.len()
1191 );
1192 stream.write_all(response.as_bytes()).await.unwrap();
1193 body
1194 });
1195
1196 let provider = OAuthClientCredentials::builder()
1197 .client_id("service-client")
1198 .client_secret("service-secret")
1199 .token_endpoint(endpoint)
1200 .token_endpoint_auth_method(OAuthTokenEndpointAuthMethod::ClientSecretPost)
1201 .resource("https://mcp.example.com/mcp")
1202 .scopes(["tools.call"])
1203 .build()
1204 .unwrap();
1205 assert_eq!(provider.get_token().await.unwrap(), "service-token");
1206
1207 let body = server.await.unwrap();
1208 assert!(body.contains("grant_type=client_credentials"));
1209 assert!(body.contains("resource=https%3A%2F%2Fmcp.example.com%2Fmcp"));
1210 assert!(body.contains("client_id=service-client"));
1211 assert!(body.contains("client_secret=service-secret"));
1212 assert!(body.contains("scope=tools.call"));
1213 }
1214
1215 #[tokio::test]
1216 async fn test_custom_token_provider() {
1217 let call_count = Arc::new(AtomicUsize::new(0));
1218 let count = call_count.clone();
1219
1220 struct CountingProvider {
1221 count: Arc<AtomicUsize>,
1222 }
1223
1224 #[async_trait]
1225 impl TokenProvider for CountingProvider {
1226 async fn get_token(&self) -> Result<String, OAuthClientError> {
1227 let n = self.count.fetch_add(1, Ordering::SeqCst);
1228 Ok(format!("token-{}", n))
1229 }
1230 }
1231
1232 let provider = CountingProvider { count };
1233
1234 assert_eq!(provider.get_token().await.unwrap(), "token-0");
1235 assert_eq!(provider.get_token().await.unwrap(), "token-1");
1236 assert_eq!(call_count.load(Ordering::SeqCst), 2);
1237 }
1238
1239 #[test]
1240 fn test_clone() {
1241 let provider = OAuthClientCredentials::builder()
1242 .client_id("id")
1243 .client_secret("secret")
1244 .token_endpoint("https://auth.example.com/token")
1245 .resource("https://mcp.example.com")
1246 .build()
1247 .unwrap();
1248
1249 let cloned = provider.clone();
1250 assert!(Arc::ptr_eq(&provider.inner, &cloned.inner));
1252 }
1253}