1use std::collections::{HashMap, HashSet};
8use std::fmt;
9use std::sync::Arc;
10
11use super::error::OAuthError;
12use super::token::TokenClaims;
13
14pub trait ScopeMatcher: Send + Sync + 'static {
20 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#[derive(Debug, Clone, Default)]
48pub struct ScopeRequirement {
49 required: HashSet<String>,
50}
51
52impl ScopeRequirement {
53 pub fn new() -> Self {
55 Self::default()
56 }
57
58 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 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 pub fn require(mut self, scope: impl Into<String>) -> Self {
74 self.required.insert(scope.into());
75 self
76 }
77
78 pub fn check(&self, claims: &TokenClaims) -> Result<(), OAuthError> {
84 self.check_with(claims, &ExactScopeMatcher)
85 }
86
87 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 pub fn required_scopes(&self) -> &HashSet<String> {
115 &self.required
116 }
117
118 pub fn is_empty(&self) -> bool {
120 self.required.is_empty()
121 }
122}
123
124#[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 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn scope_matcher(mut self, matcher: impl ScopeMatcher) -> Self {
184 self.matcher = Arc::new(matcher);
185 self
186 }
187
188 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 pub fn default_scopes(mut self, requirement: ScopeRequirement) -> Self {
196 self.default_scopes = requirement;
197 self
198 }
199
200 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 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 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 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 pub fn check_default(&self, claims: &TokenClaims) -> Result<(), OAuthError> {
246 self.default_scopes
247 .check_with(claims, self.matcher.as_ref())
248 }
249
250 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 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 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
287use 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#[derive(Debug, Clone)]
331pub struct ScopeEnforcementLayer {
332 policy: ScopePolicy,
333 require_claims: bool,
334}
335
336impl ScopeEnforcementLayer {
337 pub fn new(policy: ScopePolicy) -> Self {
339 Self {
340 policy,
341 require_claims: true,
342 }
343 }
344
345 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#[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 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 let check_result = match &req.inner {
421 McpRequest::CallTool(params) => self.policy.check_tool(¶ms.name, &claims),
422 McpRequest::ReadResource(params) => self.policy.check_resource(¶ms.uri, &claims),
423 McpRequest::GetPrompt(params) => self.policy.check_prompt(¶ms.name, &claims),
424 _ => 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 assert!(policy.check_default(&read_user).is_ok());
529 assert!(policy.check_default(&admin_user).is_ok());
530
531 assert!(policy.check_tool("dangerous", &read_user).is_err());
533 assert!(policy.check_tool("dangerous", &admin_user).is_ok());
534
535 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}