1use serde::{Deserialize, Serialize};
8use std::fmt;
9
10pub const AUTH_STATE_KEY: &str = "fastmcp.auth";
12
13#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct AccessToken {
16 pub scheme: String,
18 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 #[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 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 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 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 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct AuthContext {
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub subject: Option<String>,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub scopes: Vec<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub token: Option<AccessToken>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub claims: Option<serde_json::Value>,
288}
289
290impl AuthContext {
291 #[must_use]
293 pub fn anonymous() -> Self {
294 Self::default()
295 }
296
297 #[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}