Skip to main content

tower_mcp/client/
oauth.rs

1//! OAuth 2.0 Client Credentials grant for machine-to-machine authentication.
2//!
3//! Provides [`OAuthClientCredentials`] for acquiring and caching access tokens
4//! using the [client credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4).
5//! Tokens are cached in memory and refreshed automatically before expiry.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use tower_mcp::client::{HttpClientTransport, OAuthClientCredentials};
11//!
12//! # async fn example() -> Result<(), tower_mcp::BoxError> {
13//! // Direct token endpoint
14//! let provider = OAuthClientCredentials::builder()
15//!     .client_id("my-client")
16//!     .client_secret("my-secret")
17//!     .token_endpoint("https://auth.example.com/token")
18//!     .resource("https://mcp.example.com")
19//!     .scopes(["mcp:tools", "mcp:resources"])
20//!     .build()?;
21//!
22//! let transport = HttpClientTransport::new("http://localhost:3000")
23//!     .with_token_provider(provider);
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! # Auth Server Discovery
29//!
30//! ```rust,no_run
31//! use tower_mcp::client::OAuthClientCredentials;
32//!
33//! # async fn example() -> Result<(), tower_mcp::BoxError> {
34//! let provider = OAuthClientCredentials::discover(
35//!     "https://mcp.example.com",
36//!     "my-client",
37//!     "my-secret",
38//! ).await?;
39//! # Ok(())
40//! # }
41//! ```
42
43use 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/// Trait for dynamic token providers.
53///
54/// Implement this to provide bearer tokens that are acquired and refreshed
55/// at runtime (e.g., via OAuth 2.0 grants).
56///
57/// # Example
58///
59/// ```rust,no_run
60/// use async_trait::async_trait;
61/// use tower_mcp::client::{TokenProvider, OAuthClientError};
62///
63/// struct StaticTokenProvider(String);
64///
65/// #[async_trait]
66/// impl TokenProvider for StaticTokenProvider {
67///     async fn get_token(&self) -> Result<String, OAuthClientError> {
68///         Ok(self.0.clone())
69///     }
70/// }
71/// ```
72#[async_trait]
73pub trait TokenProvider: Send + Sync + 'static {
74    /// Get a valid bearer token.
75    ///
76    /// Implementations should cache tokens and return cached values when
77    /// still valid. This method may be called concurrently from multiple
78    /// tasks.
79    async fn get_token(&self) -> Result<String, OAuthClientError>;
80}
81
82/// Parameters from an OAuth Bearer `WWW-Authenticate` challenge.
83///
84/// MCP clients use the initial `401` challenge to locate Protected Resource
85/// Metadata and select initial scopes. The same representation also covers an
86/// `insufficient_scope` response received later in a session.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct OAuthBearerChallenge {
89    /// OAuth error code, such as `invalid_token` or `insufficient_scope`.
90    pub error: Option<String>,
91    /// Space-delimited scopes supplied by the protected resource.
92    pub scopes: Vec<String>,
93    /// Protected Resource Metadata URL supplied by the server.
94    pub resource_metadata: Option<String>,
95    /// Human-readable description supplied by the server.
96    pub error_description: Option<String>,
97}
98
99impl OAuthBearerChallenge {
100    /// Parse the first Bearer challenge from a `WWW-Authenticate` header.
101    ///
102    /// The parser handles quoted commas and escaped quoted strings, and
103    /// ignores other authentication schemes in a combined header value.
104    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/// An OAuth `insufficient_scope` Bearer challenge.
169///
170/// MCP authorization servers use this challenge on HTTP 403 responses to tell
171/// clients which additional scopes are required for the attempted operation.
172/// See [`OAuthScopeChallenge::from_www_authenticate`] for parsing.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct OAuthScopeChallenge {
175    /// Scopes requested by the authorization server.
176    pub required_scopes: Vec<String>,
177    /// Protected resource metadata URL supplied by the server.
178    pub resource_metadata: Option<String>,
179    /// Human-readable description supplied by the server.
180    pub error_description: Option<String>,
181}
182
183impl OAuthScopeChallenge {
184    /// Parse an `insufficient_scope` Bearer challenge from a
185    /// `WWW-Authenticate` header value.
186    ///
187    /// Returns `None` for other authentication schemes, other OAuth errors,
188    /// or challenges that do not include at least one required scope.
189    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/// Authentication method used at an OAuth token endpoint.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
208#[serde(rename_all = "snake_case")]
209#[non_exhaustive]
210pub enum OAuthTokenEndpointAuthMethod {
211    /// Public client authentication: send only `client_id` in the form body.
212    None,
213    /// HTTP Basic authentication using the client ID and secret.
214    ClientSecretBasic,
215    /// Client ID and secret in the form body.
216    ClientSecretPost,
217    /// JWT client assertion signed with the client's private key.
218    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/// Context passed to an [`OAuthScopeEscalationHandler`].
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct OAuthScopeEscalationRequest {
273    /// MCP protected-resource URL that rejected the request.
274    pub resource: String,
275    /// MCP operation that was rejected, such as `tools/call:search`.
276    pub operation: String,
277    /// Parsed authorization-server challenge.
278    pub challenge: OAuthScopeChallenge,
279    /// Scopes held before this escalation.
280    pub previous_scopes: Vec<String>,
281    /// Stable-order union of previous and newly required scopes.
282    pub requested_scopes: Vec<String>,
283    /// One-based retry attempt for this resource operation.
284    pub attempt: usize,
285}
286
287/// Application hook for interactive or headless OAuth reauthorization.
288///
289/// Implementations should acquire a token for `request.requested_scopes` and
290/// update the [`TokenProvider`] installed with
291/// [`HttpClientTransport::with_scope_aware_token_provider`](crate::client::HttpClientTransport::with_scope_aware_token_provider).
292/// After this method succeeds, the transport asks that provider for a fresh
293/// token and retries the rejected MCP operation.
294#[async_trait]
295pub trait OAuthScopeEscalationHandler: Send + Sync + 'static {
296    /// Reauthorize for the stable-order scope union in `request`.
297    async fn reauthorize(
298        &self,
299        request: OAuthScopeEscalationRequest,
300    ) -> Result<(), OAuthClientError>;
301}
302
303/// Runtime scope-escalation policy for `HttpClientTransport`.
304#[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    /// Create a policy with the scopes requested for the initial token.
321    ///
322    /// Duplicate and blank scope values are removed while preserving order.
323    /// By default, at most two challenged retries are permitted per MCP
324    /// operation.
325    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    /// Set the maximum number of challenged retries for one MCP operation.
340    ///
341    /// Set this to zero to surface the first HTTP 403 without reauthorizing.
342    pub fn max_attempts(mut self, max_attempts: usize) -> Self {
343        self.max_attempts = max_attempts;
344        self
345    }
346
347    /// Initial scopes tracked by the policy.
348    pub fn initial_scopes(&self) -> &[String] {
349        &self.initial_scopes
350    }
351
352    /// Maximum challenged retries for one MCP operation.
353    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/// Error type for OAuth client operations.
420#[derive(Debug)]
421#[non_exhaustive]
422pub enum OAuthClientError {
423    /// OAuth protocol HTTP request failed.
424    Http(String),
425    /// Failed to discover the authorization server metadata.
426    Discovery(String),
427    /// Failed to request a token from the token endpoint.
428    TokenRequest(String),
429    /// Failed to register an OAuth client.
430    Registration(String),
431    /// Failed to load, save, or remove persisted OAuth client credentials.
432    CredentialStore(String),
433    /// Failed to load, save, or remove persisted OAuth tokens.
434    TokenStore(String),
435    /// Failed to load, save, or remove persisted PKCE/CSRF state.
436    StateStore(String),
437    /// Authorization redirect handling failed.
438    Redirect(String),
439    /// Failed to reauthorize after an insufficient-scope challenge.
440    ScopeEscalation(String),
441    /// The token response was invalid or missing required fields.
442    InvalidResponse(String),
443    /// Builder validation failed (missing required fields).
444    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/// A cached access token with its expiration time.
468#[derive(Debug, Clone)]
469struct CachedToken {
470    access_token: String,
471    expires_at: Instant,
472}
473
474/// OAuth 2.0 token response (subset of RFC 6749 Section 5.1).
475#[derive(Debug, serde::Deserialize)]
476struct TokenResponse {
477    access_token: String,
478    #[allow(dead_code)]
479    token_type: String,
480    /// Token lifetime in seconds.
481    expires_in: Option<u64>,
482    #[allow(dead_code)]
483    scope: Option<String>,
484}
485
486/// Shared state for [`OAuthClientCredentials`].
487struct 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/// OAuth 2.0 Client Credentials token provider.
500///
501/// Acquires access tokens using the
502/// [client credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)
503/// and caches them until expiry. Uses `client_secret_basic` authentication
504/// (HTTP Basic with `client_id:client_secret`).
505///
506/// # Token Caching
507///
508/// Tokens are cached in an `RwLock` and refreshed when they expire
509/// (minus a configurable buffer, default 30 seconds). Concurrent
510/// callers share the read lock; only one caller performs the refresh
511/// while others wait on the write lock, then re-verify (double-check
512/// pattern to prevent thundering herd).
513///
514/// # Example
515///
516/// ```rust,no_run
517/// use tower_mcp::client::OAuthClientCredentials;
518/// use std::time::Duration;
519///
520/// # fn example() -> Result<(), tower_mcp::BoxError> {
521/// let provider = OAuthClientCredentials::builder()
522///     .client_id("my-service")
523///     .client_secret("s3cret")
524///     .token_endpoint("https://auth.example.com/oauth/token")
525///     .resource("https://mcp.example.com")
526///     .scopes(["mcp:tools"])
527///     .refresh_buffer(Duration::from_secs(60))
528///     .build()?;
529/// # Ok(())
530/// # }
531/// ```
532#[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    /// Create a builder for configuring the client credentials provider.
555    pub fn builder() -> OAuthClientCredentialsBuilder {
556        OAuthClientCredentialsBuilder::default()
557    }
558
559    /// Discover an MCP resource's authorization server and create a provider.
560    ///
561    /// This performs challenge-driven and path-aware Protected Resource
562    /// Metadata discovery, validates authorization-server metadata, and binds
563    /// token requests to the discovered resource using RFC 8707.
564    ///
565    /// # Errors
566    ///
567    /// Returns [`OAuthClientError::Discovery`] if the metadata endpoint
568    /// is unreachable or does not contain a `token_endpoint`.
569    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    /// Check if the cached token is still valid (not expired minus buffer).
596    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    /// Perform the token request to the authorization server.
604    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(&params)
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        // Default to 1 hour if expires_in not provided
655        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        // Fast path: read lock, return cached token if valid
669        {
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        // Slow path: write lock, double-check, then fetch
679        let mut cache = self.inner.cache.write().await;
680
681        // Another task may have refreshed while we waited for the lock
682        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/// Builder for [`OAuthClientCredentials`].
697///
698/// # Required Fields
699///
700/// - `client_id`
701/// - `client_secret`
702/// - `token_endpoint`
703/// - `resource`
704///
705/// # Example
706///
707/// ```rust,no_run
708/// use tower_mcp::client::OAuthClientCredentials;
709///
710/// # fn example() -> Result<(), tower_mcp::BoxError> {
711/// let provider = OAuthClientCredentials::builder()
712///     .client_id("my-service")
713///     .client_secret("s3cret")
714///     .token_endpoint("https://auth.example.com/token")
715///     .resource("https://mcp.example.com")
716///     .build()?;
717/// # Ok(())
718/// # }
719/// ```
720#[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    /// Set the OAuth client ID.
734    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    /// Set the OAuth client secret.
740    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    /// Set the token endpoint URL.
746    pub fn token_endpoint(mut self, url: impl Into<String>) -> Self {
747        self.token_endpoint = Some(url.into());
748        self
749    }
750
751    /// Set the token endpoint authentication method.
752    ///
753    /// Direct configurations default to `client_secret_basic`. Discovery
754    /// selects a compatible method from authorization-server metadata.
755    pub fn token_endpoint_auth_method(mut self, method: OAuthTokenEndpointAuthMethod) -> Self {
756        self.token_endpoint_auth_method = Some(method);
757        self
758    }
759
760    /// Set the canonical MCP protected-resource URI.
761    ///
762    /// The provider includes this value as the RFC 8707 `resource` parameter
763    /// in every client-credentials token request.
764    pub fn resource(mut self, resource: impl Into<String>) -> Self {
765        self.resource = Some(resource.into());
766        self
767    }
768
769    /// Set the requested scopes.
770    ///
771    /// Accepts an iterator of scope strings, which are joined with spaces
772    /// per RFC 6749 Section 3.3.
773    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    /// Set the refresh buffer duration.
782    ///
783    /// Tokens are refreshed this long before their actual expiry to avoid
784    /// using a token that expires during a request. Default: 30 seconds.
785    pub fn refresh_buffer(mut self, duration: Duration) -> Self {
786        self.refresh_buffer = Some(duration);
787        self
788    }
789
790    /// Set a custom `reqwest::Client` for token requests.
791    ///
792    /// Use this when you need custom TLS configuration or proxy settings.
793    pub fn http_client(mut self, client: reqwest::Client) -> Self {
794        self.client = Some(client);
795        self
796    }
797
798    /// Build the [`OAuthClientCredentials`] provider.
799    ///
800    /// # Errors
801    ///
802    /// Returns [`OAuthClientError::BuildError`] if `client_id`, `client_secret`,
803    /// `token_endpoint`, or `resource` are not set.
804    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        // Secret should NOT appear in debug output
948        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        // 30s buffer > 10s remaining = invalid
967        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        // Manually insert a cached token and verify get_token returns it
1103        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        // Seed the cache directly
1112        {
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        // Second call should return the same cached token
1124        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        // Seed with an expired token
1139        {
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        // get_token should try to refresh and fail (unreachable endpoint)
1148        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        // Both share the same inner state
1251        assert!(Arc::ptr_eq(&provider.inner, &cloned.inner));
1252    }
1253}