Skip to main content

fastmcp_core/
auth.rs

1//! Authentication context and access token helpers.
2//!
3//! This module provides lightweight types for representing authenticated
4//! request context. It is transport-agnostic and can be populated by
5//! server-side authentication providers.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// Session state key used to store authentication context.
11pub const AUTH_STATE_KEY: &str = "fastmcp.auth";
12
13/// Parsed access token (scheme + token value).
14#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct AccessToken {
16    /// Token scheme (e.g., "Bearer").
17    pub scheme: String,
18    /// Raw token value.
19    pub token: String,
20}
21
22impl fmt::Debug for AccessToken {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.debug_struct("AccessToken")
25            .field("scheme", &self.scheme)
26            .field("token", &"<redacted>")
27            .finish()
28    }
29}
30
31impl AccessToken {
32    /// Attempts to parse an Authorization header value.
33    ///
34    /// Accepts formats like:
35    /// - `Bearer <token>`
36    /// - `<token>` (treated as bearer)
37    #[must_use]
38    pub fn parse(value: &str) -> Option<Self> {
39        let trimmed = value.trim();
40        if trimmed.is_empty() {
41            return None;
42        }
43
44        // Special-case a common malformed Authorization value:
45        // "Bearer " (scheme with a missing token) should be rejected, even though trimming
46        // would otherwise collapse it into a single-word "Bearer" (which we treat as a
47        // bare token for non-header usages).
48        let leading = value.trim_start();
49        if let Some(prefix) = leading.get(..6) {
50            if prefix.eq_ignore_ascii_case("Bearer") {
51                let rest = &leading[6..];
52                if rest
53                    .chars()
54                    .next()
55                    .is_some_and(|ch| ch.is_ascii_whitespace())
56                    && rest.trim().is_empty()
57                {
58                    return None;
59                }
60            }
61        }
62
63        // Authorization headers use whitespace as the delimiter between scheme and token.
64        // Treat any multi-part value as invalid (tokens must not contain whitespace).
65        let mut parts = trimmed.split_whitespace();
66        let first = parts.next().unwrap_or_default();
67        if let Some(second) = parts.next() {
68            if parts.next().is_some() {
69                return None;
70            }
71            return Some(Self {
72                scheme: first.to_string(),
73                token: second.to_string(),
74            });
75        }
76
77        Some(Self {
78            scheme: "Bearer".to_string(),
79            token: trimmed.to_string(),
80        })
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::{AccessToken, AuthContext};
87
88    #[test]
89    fn parse_rejects_empty_and_scheme_without_token() {
90        assert_eq!(AccessToken::parse(""), None);
91        assert_eq!(AccessToken::parse("   "), None);
92        assert_eq!(AccessToken::parse("Bearer "), None);
93        assert_eq!(AccessToken::parse("bearer\t"), None);
94    }
95
96    #[test]
97    fn parse_accepts_bearer_scheme_and_bare_tokens() {
98        assert_eq!(
99            AccessToken::parse("Bearer abc"),
100            Some(AccessToken {
101                scheme: "Bearer".to_string(),
102                token: "abc".to_string(),
103            })
104        );
105        assert_eq!(
106            AccessToken::parse("bearer\tabc"),
107            Some(AccessToken {
108                scheme: "bearer".to_string(),
109                token: "abc".to_string(),
110            })
111        );
112        assert_eq!(
113            AccessToken::parse("abc"),
114            Some(AccessToken {
115                scheme: "Bearer".to_string(),
116                token: "abc".to_string(),
117            })
118        );
119        // A single "Bearer" token is accepted as a bare token.
120        assert_eq!(
121            AccessToken::parse("Bearer"),
122            Some(AccessToken {
123                scheme: "Bearer".to_string(),
124                token: "Bearer".to_string(),
125            })
126        );
127    }
128
129    #[test]
130    fn parse_rejects_values_with_multiple_whitespace_separated_parts() {
131        assert_eq!(AccessToken::parse("Bearer a b"), None);
132        assert_eq!(AccessToken::parse("Token a b c"), None);
133    }
134
135    #[test]
136    fn parse_accepts_non_bearer_schemes() {
137        assert_eq!(
138            AccessToken::parse("Token abc"),
139            Some(AccessToken {
140                scheme: "Token".to_string(),
141                token: "abc".to_string(),
142            })
143        );
144    }
145
146    #[test]
147    fn auth_context_constructors() {
148        let anon = AuthContext::anonymous();
149        assert!(anon.subject.is_none());
150        assert!(anon.scopes.is_empty());
151        assert!(anon.token.is_none());
152        assert!(anon.claims.is_none());
153
154        let user = AuthContext::with_subject("user123");
155        assert_eq!(user.subject.as_deref(), Some("user123"));
156        assert!(user.scopes.is_empty());
157        assert!(user.token.is_none());
158        assert!(user.claims.is_none());
159    }
160
161    #[test]
162    fn auth_context_serialization_skips_empty_fields() {
163        let anon = AuthContext::anonymous();
164        let value = serde_json::to_value(&anon).expect("serialize");
165        assert_eq!(value, serde_json::json!({}));
166    }
167
168    // =========================================================================
169    // Additional coverage tests (bd-1p24)
170    // =========================================================================
171
172    #[test]
173    fn auth_state_key_constant() {
174        assert_eq!(super::AUTH_STATE_KEY, "fastmcp.auth");
175    }
176
177    #[test]
178    fn auth_context_default_is_anonymous() {
179        let def = AuthContext::default();
180        assert!(def.subject.is_none());
181        assert!(def.scopes.is_empty());
182        assert!(def.token.is_none());
183        assert!(def.claims.is_none());
184    }
185
186    #[test]
187    fn auth_context_debug_output() {
188        let ctx = AuthContext::with_subject("alice");
189        let debug = format!("{ctx:?}");
190        assert!(debug.contains("AuthContext"));
191        assert!(debug.contains("alice"));
192    }
193
194    #[test]
195    fn auth_context_clone() {
196        let ctx = AuthContext::with_subject("bob");
197        let cloned = ctx.clone();
198        assert_eq!(cloned.subject.as_deref(), Some("bob"));
199    }
200
201    #[test]
202    fn auth_context_full_serialization_roundtrip() {
203        let ctx = AuthContext {
204            subject: Some("user42".to_string()),
205            scopes: vec!["read".to_string(), "write".to_string()],
206            token: Some(AccessToken {
207                scheme: "Bearer".to_string(),
208                token: "tok123".to_string(),
209            }),
210            claims: Some(serde_json::json!({"aud": "api"})),
211        };
212        let json = serde_json::to_value(&ctx).expect("serialize");
213        assert_eq!(json["subject"], "user42");
214        assert_eq!(json["scopes"], serde_json::json!(["read", "write"]));
215        assert_eq!(json["token"]["scheme"], "Bearer");
216        assert_eq!(json["token"]["token"], "tok123");
217        assert_eq!(json["claims"]["aud"], "api");
218
219        // Roundtrip
220        let deserialized: AuthContext = serde_json::from_value(json).expect("deserialize");
221        assert_eq!(deserialized.subject.as_deref(), Some("user42"));
222        assert_eq!(deserialized.scopes.len(), 2);
223        assert!(deserialized.token.is_some());
224        assert!(deserialized.claims.is_some());
225    }
226
227    #[test]
228    fn access_token_debug_clone_eq() {
229        let token = AccessToken {
230            scheme: "Bearer".to_string(),
231            token: "abc".to_string(),
232        };
233        let debug = format!("{token:?}");
234        assert!(debug.contains("AccessToken"));
235        assert!(debug.contains("Bearer"));
236        assert!(debug.contains("<redacted>"));
237        assert!(!debug.contains("abc"));
238
239        let cloned = token.clone();
240        assert_eq!(token, cloned);
241    }
242
243    #[test]
244    fn auth_context_debug_redacts_nested_token() {
245        let ctx = AuthContext {
246            subject: Some("user42".to_string()),
247            scopes: vec!["read".to_string()],
248            token: Some(AccessToken {
249                scheme: "Bearer".to_string(),
250                token: "super-secret-token".to_string(),
251            }),
252            claims: None,
253        };
254
255        let debug = format!("{ctx:?}");
256        assert!(debug.contains("AuthContext"));
257        assert!(debug.contains("<redacted>"));
258        assert!(!debug.contains("super-secret-token"));
259    }
260
261    #[test]
262    fn access_token_serde_roundtrip() {
263        let token = AccessToken {
264            scheme: "Custom".to_string(),
265            token: "xyz".to_string(),
266        };
267        let json = serde_json::to_string(&token).expect("serialize");
268        let deserialized: AccessToken = serde_json::from_str(&json).expect("deserialize");
269        assert_eq!(deserialized, token);
270    }
271}
272
273/// Authentication context stored for a request/session.
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct AuthContext {
276    /// Subject identifier (user or client ID).
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub subject: Option<String>,
279    /// Authorized scopes for this subject.
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub scopes: Vec<String>,
282    /// Access token (if available).
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub token: Option<AccessToken>,
285    /// Optional raw claims (transport or provider specific).
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub claims: Option<serde_json::Value>,
288}
289
290impl AuthContext {
291    /// Creates an anonymous context (no subject, no scopes).
292    #[must_use]
293    pub fn anonymous() -> Self {
294        Self::default()
295    }
296
297    /// Creates a context with a subject identifier.
298    #[must_use]
299    pub fn with_subject(subject: impl Into<String>) -> Self {
300        Self {
301            subject: Some(subject.into()),
302            ..Self::default()
303        }
304    }
305}