Skip to main content

tower_mcp/oauth/
scope.rs

1//! OAuth scope types and per-operation scope policy.
2//!
3//! Provides [`ScopeRequirement`] for defining required scopes and
4//! [`ScopePolicy`] for mapping operations (tools, resources, prompts)
5//! to their required scopes.
6
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9use std::sync::Arc;
10
11use super::error::OAuthError;
12use super::token::TokenClaims;
13
14/// Determines whether a granted OAuth scope satisfies a required scope.
15///
16/// The default matcher uses exact string equality. Implement this trait (or
17/// pass a closure to [`ScopePolicy::scope_matcher`]) when an authorization
18/// server defines hierarchical scopes such as `mcp:*` implying `mcp:read`.
19pub trait ScopeMatcher: Send + Sync + 'static {
20    /// Return `true` when `granted` authorizes an operation requiring
21    /// `required`.
22    fn matches(&self, granted: &str, required: &str) -> bool;
23}
24
25impl<F> ScopeMatcher for F
26where
27    F: Fn(&str, &str) -> bool + Send + Sync + 'static,
28{
29    fn matches(&self, granted: &str, required: &str) -> bool {
30        self(granted, required)
31    }
32}
33
34#[derive(Debug, Default)]
35struct ExactScopeMatcher;
36
37impl ScopeMatcher for ExactScopeMatcher {
38    fn matches(&self, granted: &str, required: &str) -> bool {
39        granted == required
40    }
41}
42
43/// A set of required OAuth scopes for an operation.
44///
45/// All scopes in the requirement must be present in the token for access
46/// to be granted (AND semantics).
47#[derive(Debug, Clone, Default)]
48pub struct ScopeRequirement {
49    required: HashSet<String>,
50}
51
52impl ScopeRequirement {
53    /// Create an empty scope requirement (no scopes needed).
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Create a scope requirement from a single scope.
59    pub fn one(scope: impl Into<String>) -> Self {
60        let mut required = HashSet::new();
61        required.insert(scope.into());
62        Self { required }
63    }
64
65    /// Create a scope requirement from multiple scopes.
66    pub fn all(scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
67        Self {
68            required: scopes.into_iter().map(Into::into).collect(),
69        }
70    }
71
72    /// Add a required scope to this requirement.
73    pub fn require(mut self, scope: impl Into<String>) -> Self {
74        self.required.insert(scope.into());
75        self
76    }
77
78    /// Check if the given token claims satisfy this requirement.
79    ///
80    /// Returns `Ok(())` if all required scopes are present, or
81    /// `Err(OAuthError::InsufficientScope)` with details about
82    /// which scopes are missing.
83    pub fn check(&self, claims: &TokenClaims) -> Result<(), OAuthError> {
84        self.check_with(claims, &ExactScopeMatcher)
85    }
86
87    /// Check the requirement using a custom scope matcher.
88    pub fn check_with(
89        &self,
90        claims: &TokenClaims,
91        matcher: &dyn ScopeMatcher,
92    ) -> Result<(), OAuthError> {
93        if self.required.is_empty() {
94            return Ok(());
95        }
96
97        let provided = claims.scopes();
98        let satisfied = self.required.iter().all(|required| {
99            provided
100                .iter()
101                .any(|granted| matcher.matches(granted, required))
102        });
103        if satisfied {
104            Ok(())
105        } else {
106            Err(OAuthError::InsufficientScope {
107                required: self.required.iter().cloned().collect(),
108                provided: provided.into_iter().collect(),
109            })
110        }
111    }
112
113    /// Returns the required scopes.
114    pub fn required_scopes(&self) -> &HashSet<String> {
115        &self.required
116    }
117
118    /// Returns true if no scopes are required.
119    pub fn is_empty(&self) -> bool {
120        self.required.is_empty()
121    }
122}
123
124/// Policy mapping MCP operations to their required OAuth scopes.
125///
126/// Allows configuring per-tool, per-resource, and per-prompt scope
127/// requirements, with a default fallback.
128///
129/// # Example
130///
131/// ```rust
132/// use tower_mcp::oauth::ScopePolicy;
133///
134/// let policy = ScopePolicy::new()
135///     .default_scope("mcp:read")
136///     .tool_scope("dangerous_tool", "mcp:admin")
137///     .resource_scope("secret://data", "mcp:secret");
138/// ```
139#[derive(Clone)]
140pub struct ScopePolicy {
141    default_scopes: ScopeRequirement,
142    tool_scopes: HashMap<String, ScopeRequirement>,
143    resource_scopes: HashMap<String, ScopeRequirement>,
144    prompt_scopes: HashMap<String, ScopeRequirement>,
145    matcher: Arc<dyn ScopeMatcher>,
146}
147
148impl Default for ScopePolicy {
149    fn default() -> Self {
150        Self {
151            default_scopes: ScopeRequirement::default(),
152            tool_scopes: HashMap::new(),
153            resource_scopes: HashMap::new(),
154            prompt_scopes: HashMap::new(),
155            matcher: Arc::new(ExactScopeMatcher),
156        }
157    }
158}
159
160impl fmt::Debug for ScopePolicy {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter
163            .debug_struct("ScopePolicy")
164            .field("default_scopes", &self.default_scopes)
165            .field("tool_scopes", &self.tool_scopes)
166            .field("resource_scopes", &self.resource_scopes)
167            .field("prompt_scopes", &self.prompt_scopes)
168            .field("matcher", &"<scope matcher>")
169            .finish()
170    }
171}
172
173impl ScopePolicy {
174    /// Create an empty scope policy (no scopes required for anything).
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Use a custom matcher for exact or hierarchical scope semantics.
180    ///
181    /// The matcher receives `(granted, required)` and should return `true`
182    /// when the granted scope authorizes the required scope.
183    pub fn scope_matcher(mut self, matcher: impl ScopeMatcher) -> Self {
184        self.matcher = Arc::new(matcher);
185        self
186    }
187
188    /// Set a default scope required for all operations.
189    pub fn default_scope(mut self, scope: impl Into<String>) -> Self {
190        self.default_scopes = self.default_scopes.require(scope);
191        self
192    }
193
194    /// Set a default scope requirement for all operations.
195    pub fn default_scopes(mut self, requirement: ScopeRequirement) -> Self {
196        self.default_scopes = requirement;
197        self
198    }
199
200    /// Set scope requirement for a specific tool.
201    ///
202    /// The tool scope is checked *in addition* to the default scope.
203    pub fn tool_scope(mut self, tool_name: impl Into<String>, scope: impl Into<String>) -> Self {
204        let name = tool_name.into();
205        let entry = self.tool_scopes.entry(name).or_default();
206        entry.required.insert(scope.into());
207        self
208    }
209
210    /// Set scope requirement for a specific tool with a full requirement.
211    pub fn tool_scopes(
212        mut self,
213        tool_name: impl Into<String>,
214        requirement: ScopeRequirement,
215    ) -> Self {
216        self.tool_scopes.insert(tool_name.into(), requirement);
217        self
218    }
219
220    /// Set scope requirement for a specific resource.
221    pub fn resource_scope(
222        mut self,
223        resource_uri: impl Into<String>,
224        scope: impl Into<String>,
225    ) -> Self {
226        let uri = resource_uri.into();
227        let entry = self.resource_scopes.entry(uri).or_default();
228        entry.required.insert(scope.into());
229        self
230    }
231
232    /// Set scope requirement for a specific prompt.
233    pub fn prompt_scope(
234        mut self,
235        prompt_name: impl Into<String>,
236        scope: impl Into<String>,
237    ) -> Self {
238        let name = prompt_name.into();
239        let entry = self.prompt_scopes.entry(name).or_default();
240        entry.required.insert(scope.into());
241        self
242    }
243
244    /// Check if the given claims satisfy the default scope requirement.
245    pub fn check_default(&self, claims: &TokenClaims) -> Result<(), OAuthError> {
246        self.default_scopes
247            .check_with(claims, self.matcher.as_ref())
248    }
249
250    /// Check if the given claims satisfy the scope requirement for a tool.
251    ///
252    /// Checks both default scopes and tool-specific scopes.
253    pub fn check_tool(&self, tool_name: &str, claims: &TokenClaims) -> Result<(), OAuthError> {
254        self.default_scopes
255            .check_with(claims, self.matcher.as_ref())?;
256        if let Some(req) = self.tool_scopes.get(tool_name) {
257            req.check_with(claims, self.matcher.as_ref())?;
258        }
259        Ok(())
260    }
261
262    /// Check if the given claims satisfy the scope requirement for a resource.
263    pub fn check_resource(
264        &self,
265        resource_uri: &str,
266        claims: &TokenClaims,
267    ) -> Result<(), OAuthError> {
268        self.default_scopes
269            .check_with(claims, self.matcher.as_ref())?;
270        if let Some(req) = self.resource_scopes.get(resource_uri) {
271            req.check_with(claims, self.matcher.as_ref())?;
272        }
273        Ok(())
274    }
275
276    /// Check if the given claims satisfy the scope requirement for a prompt.
277    pub fn check_prompt(&self, prompt_name: &str, claims: &TokenClaims) -> Result<(), OAuthError> {
278        self.default_scopes
279            .check_with(claims, self.matcher.as_ref())?;
280        if let Some(req) = self.prompt_scopes.get(prompt_name) {
281            req.check_with(claims, self.matcher.as_ref())?;
282        }
283        Ok(())
284    }
285}
286
287// =============================================================================
288// ScopeEnforcementLayer -- tower middleware at the MCP RouterRequest level
289// =============================================================================
290
291use std::convert::Infallible;
292use std::future::Future;
293use std::pin::Pin;
294use std::task::{Context, Poll};
295
296use tower::Layer;
297use tower_service::Service;
298
299use crate::error::JsonRpcError;
300use crate::protocol::McpRequest;
301use crate::router::{RouterRequest, RouterResponse};
302
303/// Tower layer that enforces OAuth scope requirements at the MCP request level.
304///
305/// Unlike [`OAuthLayer`](super::OAuthLayer) which operates at the HTTP transport
306/// level, `ScopeEnforcementLayer` operates on [`RouterRequest`] and can perform
307/// per-operation scope checks (e.g., different scopes for different tools).
308///
309/// The middleware extracts [`TokenClaims`] from [`RouterRequest::extensions`]. If
310/// no claims are present, the request is rejected. Use
311/// [`ScopeEnforcementLayer::permissive_without_claims`] only when a surrounding
312/// component intentionally supports unauthenticated requests.
313///
314/// # Example
315///
316/// ```rust,no_run
317/// use std::time::Duration;
318/// use tower_mcp::McpRouter;
319/// use tower_mcp::oauth::{ScopePolicy, ScopeEnforcementLayer};
320/// use tower_mcp::transport::http::HttpTransport;
321///
322/// let policy = ScopePolicy::new()
323///     .default_scope("mcp:read")
324///     .tool_scope("admin_tool", "mcp:admin");
325///
326/// let router = McpRouter::new().server_info("my-server", "1.0.0");
327/// let transport = HttpTransport::new(router)
328///     .layer(ScopeEnforcementLayer::new(policy));
329/// ```
330#[derive(Debug, Clone)]
331pub struct ScopeEnforcementLayer {
332    policy: ScopePolicy,
333    require_claims: bool,
334}
335
336impl ScopeEnforcementLayer {
337    /// Create a new scope enforcement layer with the given policy.
338    pub fn new(policy: ScopePolicy) -> Self {
339        Self {
340            policy,
341            require_claims: true,
342        }
343    }
344
345    /// Create a layer that skips scope checks when authentication claims are absent.
346    ///
347    /// This is an explicit opt-out from fail-closed behavior. Prefer [`Self::new`]
348    /// for protected MCP endpoints.
349    pub fn permissive_without_claims(policy: ScopePolicy) -> Self {
350        Self {
351            policy,
352            require_claims: false,
353        }
354    }
355}
356
357impl<S> Layer<S> for ScopeEnforcementLayer {
358    type Service = ScopeEnforcementService<S>;
359
360    fn layer(&self, inner: S) -> Self::Service {
361        ScopeEnforcementService {
362            inner,
363            policy: self.policy.clone(),
364            require_claims: self.require_claims,
365        }
366    }
367}
368
369/// Tower service that enforces OAuth scope requirements on MCP requests.
370///
371/// Created by [`ScopeEnforcementLayer`]. For each incoming `RouterRequest`:
372///
373/// 1. Extracts [`TokenClaims`] from `req.extensions`
374/// 2. If no claims are present, rejects the request unless permissive mode was requested
375/// 3. Matches the request type (`CallTool`, `ReadResource`, `GetPrompt`, etc.)
376/// 4. Checks the appropriate scope requirement from the [`ScopePolicy`]
377/// 5. On failure, returns a `RouterResponse` with a JSON-RPC forbidden error
378/// 6. On success, forwards to the inner service
379#[derive(Debug, Clone)]
380pub struct ScopeEnforcementService<S> {
381    inner: S,
382    policy: ScopePolicy,
383    require_claims: bool,
384}
385
386impl<S> Service<RouterRequest> for ScopeEnforcementService<S>
387where
388    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
389        + Clone
390        + Send
391        + 'static,
392    S::Future: Send,
393{
394    type Response = RouterResponse;
395    type Error = Infallible;
396    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
397
398    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
399        self.inner.poll_ready(cx)
400    }
401
402    fn call(&mut self, req: RouterRequest) -> Self::Future {
403        // Extract claims from extensions; fail closed unless explicitly configured otherwise.
404        let claims = req.extensions.get::<TokenClaims>().cloned();
405
406        let Some(claims) = claims else {
407            if self.require_claims {
408                let response = RouterResponse {
409                    id: req.id,
410                    inner: Err(JsonRpcError::forbidden(
411                        "authenticated token claims are required",
412                    )),
413                };
414                return Box::pin(async move { Ok(response) });
415            }
416            return Box::pin(self.inner.call(req));
417        };
418
419        // Check scope based on request type
420        let check_result = match &req.inner {
421            McpRequest::CallTool(params) => self.policy.check_tool(&params.name, &claims),
422            McpRequest::ReadResource(params) => self.policy.check_resource(&params.uri, &claims),
423            McpRequest::GetPrompt(params) => self.policy.check_prompt(&params.name, &claims),
424            // All other request types use the default scope check
425            _ => self.policy.check_default(&claims),
426        };
427
428        if let Err(err) = check_result {
429            let response = RouterResponse {
430                id: req.id,
431                inner: Err(JsonRpcError::forbidden(err.to_string())),
432            };
433            return Box::pin(async move { Ok(response) });
434        }
435
436        let fut = self.inner.call(req);
437        Box::pin(fut)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::collections::HashMap;
445
446    fn claims_with_scopes(scopes: &str) -> TokenClaims {
447        TokenClaims {
448            sub: Some("user".to_string()),
449            iss: None,
450            aud: None,
451            exp: None,
452            scope: Some(scopes.to_string()),
453            client_id: None,
454            extra: HashMap::new(),
455        }
456    }
457
458    fn claims_no_scopes() -> TokenClaims {
459        TokenClaims {
460            sub: Some("user".to_string()),
461            iss: None,
462            aud: None,
463            exp: None,
464            scope: None,
465            client_id: None,
466            extra: HashMap::new(),
467        }
468    }
469
470    #[test]
471    fn test_scope_requirement_empty() {
472        let req = ScopeRequirement::new();
473        assert!(req.is_empty());
474        assert!(req.check(&claims_no_scopes()).is_ok());
475    }
476
477    #[test]
478    fn test_scope_requirement_one() {
479        let req = ScopeRequirement::one("mcp:read");
480        assert!(!req.is_empty());
481        assert!(req.check(&claims_with_scopes("mcp:read mcp:write")).is_ok());
482        assert!(req.check(&claims_no_scopes()).is_err());
483    }
484
485    #[test]
486    fn test_scope_requirement_all() {
487        let req = ScopeRequirement::all(["mcp:read", "mcp:write"]);
488        assert!(req.check(&claims_with_scopes("mcp:read mcp:write")).is_ok());
489        assert!(req.check(&claims_with_scopes("mcp:read")).is_err());
490    }
491
492    #[test]
493    fn test_scope_requirement_insufficient() {
494        let req = ScopeRequirement::one("mcp:admin");
495        let result = req.check(&claims_with_scopes("mcp:read"));
496        assert!(result.is_err());
497
498        if let Err(OAuthError::InsufficientScope { required, provided }) = result {
499            assert!(required.contains(&"mcp:admin".to_string()));
500            assert!(provided.contains(&"mcp:read".to_string()));
501        } else {
502            panic!("Expected InsufficientScope error");
503        }
504    }
505
506    #[test]
507    fn test_scope_policy_default() {
508        let policy = ScopePolicy::new().default_scope("mcp:read");
509
510        assert!(
511            policy
512                .check_default(&claims_with_scopes("mcp:read"))
513                .is_ok()
514        );
515        assert!(policy.check_default(&claims_no_scopes()).is_err());
516    }
517
518    #[test]
519    fn test_scope_policy_tool_scope() {
520        let policy = ScopePolicy::new()
521            .default_scope("mcp:read")
522            .tool_scope("dangerous", "mcp:admin");
523
524        let read_user = claims_with_scopes("mcp:read");
525        let admin_user = claims_with_scopes("mcp:read mcp:admin");
526
527        // Default check passes for both
528        assert!(policy.check_default(&read_user).is_ok());
529        assert!(policy.check_default(&admin_user).is_ok());
530
531        // Tool-specific check needs both default + tool scopes
532        assert!(policy.check_tool("dangerous", &read_user).is_err());
533        assert!(policy.check_tool("dangerous", &admin_user).is_ok());
534
535        // Unknown tool only needs default scopes
536        assert!(policy.check_tool("safe", &read_user).is_ok());
537    }
538
539    #[test]
540    fn test_scope_policy_resource_scope() {
541        let policy = ScopePolicy::new().resource_scope("secret://data", "mcp:secret");
542
543        let user = claims_with_scopes("mcp:secret");
544        let user_no_secret = claims_with_scopes("mcp:read");
545
546        assert!(policy.check_resource("secret://data", &user).is_ok());
547        assert!(
548            policy
549                .check_resource("secret://data", &user_no_secret)
550                .is_err()
551        );
552        assert!(
553            policy
554                .check_resource("public://data", &user_no_secret)
555                .is_ok()
556        );
557    }
558
559    #[test]
560    fn test_scope_policy_prompt_scope() {
561        let policy = ScopePolicy::new().prompt_scope("admin-prompt", "mcp:admin");
562
563        let admin = claims_with_scopes("mcp:admin");
564        let user = claims_with_scopes("mcp:read");
565
566        assert!(policy.check_prompt("admin-prompt", &admin).is_ok());
567        assert!(policy.check_prompt("admin-prompt", &user).is_err());
568        assert!(policy.check_prompt("public-prompt", &user).is_ok());
569    }
570
571    #[test]
572    fn test_scope_policy_empty() {
573        let policy = ScopePolicy::new();
574        assert!(policy.check_default(&claims_no_scopes()).is_ok());
575        assert!(policy.check_tool("any", &claims_no_scopes()).is_ok());
576        assert!(
577            policy
578                .check_resource("any://uri", &claims_no_scopes())
579                .is_ok()
580        );
581        assert!(policy.check_prompt("any", &claims_no_scopes()).is_ok());
582    }
583
584    #[test]
585    fn test_scope_policy_custom_hierarchy() {
586        let policy = ScopePolicy::new()
587            .default_scope("mcp:read")
588            .tool_scope("admin", "mcp:admin")
589            .scope_matcher(|granted: &str, required: &str| {
590                granted == required || granted == "mcp:*"
591            });
592        let claims = claims_with_scopes("mcp:*");
593
594        assert!(policy.check_default(&claims).is_ok());
595        assert!(policy.check_tool("admin", &claims).is_ok());
596    }
597
598    #[test]
599    fn test_scope_enforcement_is_fail_closed_by_default() {
600        assert!(ScopeEnforcementLayer::new(ScopePolicy::new()).require_claims);
601        assert!(
602            !ScopeEnforcementLayer::permissive_without_claims(ScopePolicy::new()).require_claims
603        );
604    }
605}