Skip to main content

flymcprust_core/
auth.rs

1//! Authorization: the authenticated caller ([`Principal`]), access-token
2//! verification ([`TokenVerifier`]) and scope checks.
3//!
4//! The Streamable HTTP transport acts as an OAuth 2.1 resource server when
5//! configured with [`OAuth`](crate::transport::http::OAuth): every request
6//! must carry a valid bearer token, and handlers that declare scopes (for
7//! example `#[tool(scopes = ["files:write"])]`) run only when the token grants
8//! them. stdio is not authorized this way: per the specification, it takes
9//! credentials from its environment.
10
11#[cfg(feature = "oauth")]
12mod jwt;
13
14#[cfg(feature = "oauth")]
15pub use jwt::{Algorithm, JwtError, JwtVerifier, JwtVerifierBuilder};
16
17use std::collections::BTreeSet;
18use std::fmt;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::Arc;
22
23use serde_json::{Map, Value};
24use sha2::{Digest as _, Sha256};
25
26use crate::context::Ctx;
27use crate::error::McpError;
28
29/// The caller authenticated by an access token.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Principal {
32    subject: String,
33    issuer: Option<String>,
34    scopes: BTreeSet<String>,
35    claims: Map<String, Value>,
36}
37
38impl Principal {
39    /// A principal identified by `subject` (the token's `sub`).
40    #[must_use]
41    pub fn new(subject: impl Into<String>) -> Self {
42        Self {
43            subject: subject.into(),
44            issuer: None,
45            scopes: BTreeSet::new(),
46            claims: Map::new(),
47        }
48    }
49
50    /// Sets the token issuer.
51    #[must_use]
52    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
53        self.issuer = Some(issuer.into());
54        self
55    }
56
57    /// Sets the granted scopes.
58    #[must_use]
59    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
60    where
61        I: IntoIterator<Item = S>,
62        S: Into<String>,
63    {
64        self.scopes = scopes.into_iter().map(Into::into).collect();
65        self
66    }
67
68    /// Sets the other claims of the token.
69    #[must_use]
70    pub fn with_claims(mut self, claims: Map<String, Value>) -> Self {
71        self.claims = claims;
72        self
73    }
74
75    /// The subject (`sub`).
76    #[must_use]
77    pub fn subject(&self) -> &str {
78        &self.subject
79    }
80
81    /// The issuer (`iss`), when known.
82    #[must_use]
83    pub fn issuer(&self) -> Option<&str> {
84        self.issuer.as_deref()
85    }
86
87    /// The granted scopes.
88    #[must_use]
89    pub fn scopes(&self) -> &BTreeSet<String> {
90        &self.scopes
91    }
92
93    /// Whether `scope` was granted (exactly; see
94    /// [`OAuthBuilder::scope_implies`](crate::transport::http::OAuthBuilder::scope_implies)
95    /// for hierarchies).
96    #[must_use]
97    pub fn has_scope(&self, scope: &str) -> bool {
98        self.scopes.contains(scope)
99    }
100
101    /// The token's other claims.
102    #[must_use]
103    pub fn claims(&self) -> &Map<String, Value> {
104        &self.claims
105    }
106
107    /// A stable, non-reversible identifier of issuer and subject, used to
108    /// bind `requestState` and legacy sessions to this principal.
109    #[cfg_attr(not(feature = "http"), allow(dead_code))]
110    pub(crate) fn digest(&self) -> String {
111        let mut hasher = Sha256::new();
112        hasher.update(self.issuer.as_deref().unwrap_or_default());
113        hasher.update([0]);
114        hasher.update(&self.subject);
115        let digest = hasher.finalize();
116        digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
117    }
118}
119
120/// Why a token was rejected. The reason is logged on the server; clients
121/// only learn that the token is invalid.
122#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
123#[error("invalid access token: {reason}")]
124pub struct TokenError {
125    reason: String,
126}
127
128impl TokenError {
129    /// A rejection with a reason for the server log.
130    pub fn new(reason: impl Into<String>) -> Self {
131        Self {
132            reason: reason.into(),
133        }
134    }
135
136    /// The reason (for logs; never sent to clients).
137    #[must_use]
138    pub fn reason(&self) -> &str {
139        &self.reason
140    }
141}
142
143/// The future returned by [`TokenVerifier::verify`].
144pub type VerifyFuture<'a> =
145    Pin<Box<dyn Future<Output = Result<Principal, TokenError>> + Send + 'a>>;
146
147/// Checks an access token and identifies its holder.
148///
149/// Implementations **must** check that the token was issued for this server
150/// (audience), is unexpired, and is authentic; the specification forbids
151/// accepting tokens meant for other resources. The `oauth` feature provides
152/// `JwtVerifier` for JWT access tokens; implement this trait for other
153/// formats (for example token introspection).
154pub trait TokenVerifier: Send + Sync + 'static {
155    /// Verifies `token` (the value after `Bearer `).
156    fn verify<'a>(&'a self, token: &'a str) -> VerifyFuture<'a>;
157}
158
159impl<F> TokenVerifier for F
160where
161    F: Fn(&str) -> Result<Principal, TokenError> + Send + Sync + 'static,
162{
163    fn verify<'a>(&'a self, token: &'a str) -> VerifyFuture<'a> {
164        let result = self(token);
165        Box::pin(async move { result })
166    }
167}
168
169/// Whether `scope` is a valid OAuth scope token (RFC 6749 ยง3.3: visible
170/// ASCII except `"` and `\`, no spaces).
171pub(crate) fn is_valid_scope(scope: &str) -> bool {
172    !scope.is_empty()
173        && scope
174            .bytes()
175            .all(|b| matches!(b, 0x21 | 0x23..=0x5B | 0x5D..=0x7E))
176}
177
178/// Decides whether a granted scope satisfies a required one (scope
179/// hierarchies: `files` might imply `files:read`).
180pub type ScopeImplies = fn(granted: &str, required: &str) -> bool;
181
182/// The authorization of the current request, set by the HTTP transport when
183/// OAuth is configured.
184#[derive(Clone)]
185pub(crate) struct AuthContext {
186    pub(crate) principal: Arc<Principal>,
187    pub(crate) implies: Option<ScopeImplies>,
188    /// [`Principal::digest`], computed once.
189    pub(crate) digest: String,
190}
191
192impl fmt::Debug for AuthContext {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.debug_struct("AuthContext")
195            .field("subject", &self.principal.subject())
196            .finish_non_exhaustive()
197    }
198}
199
200impl AuthContext {
201    #[cfg_attr(not(feature = "http"), allow(dead_code))]
202    pub(crate) fn new(principal: Principal, implies: Option<ScopeImplies>) -> Self {
203        let digest = principal.digest();
204        Self {
205            principal: Arc::new(principal),
206            implies,
207            digest,
208        }
209    }
210
211    /// The required scopes that the token does not grant.
212    pub(crate) fn missing<'a>(&self, required: &'a [String]) -> Vec<&'a str> {
213        required
214            .iter()
215            .filter(|needed| {
216                !self.principal.scopes.iter().any(|granted| {
217                    granted == *needed || self.implies.is_some_and(|f| f(granted, needed))
218                })
219            })
220            .map(String::as_str)
221            .collect()
222    }
223
224    /// Checks a handler's scopes; the error lists every scope it needs.
225    pub(crate) fn check(&self, required: &[String]) -> Result<(), McpError> {
226        if self.missing(required).is_empty() {
227            Ok(())
228        } else {
229            Err(McpError::insufficient_scope(required))
230        }
231    }
232}
233
234impl Ctx {
235    /// The caller authenticated by the HTTP transport's OAuth layer; `None`
236    /// when the server does not require authorization (or over stdio).
237    #[must_use]
238    pub fn principal(&self) -> Option<Arc<Principal>> {
239        self.transport()
240            .get::<AuthContext>()
241            .map(|auth| Arc::clone(&auth.principal))
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn auth(scopes: &[&str], implies: Option<ScopeImplies>) -> AuthContext {
250        AuthContext::new(
251            Principal::new("u").with_scopes(scopes.iter().copied()),
252            implies,
253        )
254    }
255
256    #[test]
257    fn missing_scopes_are_listed_together() {
258        let required = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
259        assert_eq!(auth(&["b"], None).missing(&required), ["a", "c"]);
260        assert!(auth(&["a", "b", "c"], None).check(&required).is_ok());
261    }
262
263    #[test]
264    fn scope_hierarchies() {
265        fn implies(granted: &str, required: &str) -> bool {
266            required.starts_with(&format!("{granted}:"))
267        }
268        let required = vec!["files:read".to_owned()];
269        assert!(auth(&["files"], Some(implies)).check(&required).is_ok());
270        assert!(auth(&["files"], None).check(&required).is_err());
271    }
272
273    #[test]
274    fn digests_differ_by_issuer_and_subject() {
275        let a = Principal::new("alice").with_issuer("https://a");
276        let b = Principal::new("alice").with_issuer("https://b");
277        assert_ne!(a.digest(), b.digest());
278        assert_eq!(a.digest(), a.clone().digest());
279        assert_eq!(a.digest().len(), 32);
280    }
281}