1use std::sync::Arc;
10
11use axum::body::Body;
12use axum::extract::State;
13use axum::http::{HeaderMap, Request, StatusCode};
14use axum::middleware::Next;
15use axum::response::{IntoResponse, Response};
16use axum::Json;
17use subtle::ConstantTimeEq;
18
19use crate::config::{McpConfig, McpScope};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct McpPrincipal {
24 pub name: Option<String>,
26 pub scope: McpScope,
28}
29
30pub struct McpAuthState {
32 tokens: Vec<(Vec<u8>, McpPrincipal)>,
33 allowed_origins: Vec<String>,
34}
35
36impl McpAuthState {
37 pub fn from_config(cfg: &McpConfig) -> Self {
39 Self {
40 tokens: cfg
41 .tokens
42 .iter()
43 .map(|t| {
44 (
45 t.token.as_bytes().to_vec(),
46 McpPrincipal {
47 name: t.name.clone(),
48 scope: t.scope,
49 },
50 )
51 })
52 .collect(),
53 allowed_origins: cfg.allowed_origins.clone(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum AuthFailure {
62 OriginNotAllowed,
64 Unauthorized,
66}
67
68fn origin_authority(origin: &str) -> Option<&str> {
70 let rest = origin.split_once("://")?.1;
71 let authority = rest.split('/').next()?;
72 (!authority.is_empty()).then_some(authority)
73}
74
75pub fn authenticate_for(
85 auth: &McpAuthState,
86 headers: &HeaderMap,
87 authority: Option<&str>,
88) -> Result<McpPrincipal, AuthFailure> {
89 if let Some(origin) = headers.get("origin") {
90 let origin = origin.to_str().map_err(|_| AuthFailure::OriginNotAllowed)?;
91 let listed = auth.allowed_origins.iter().any(|a| a == origin);
92 let same_origin = match (origin_authority(origin), authority) {
93 (Some(o), Some(h)) => o.eq_ignore_ascii_case(h),
94 _ => false,
95 };
96 if !listed && !same_origin {
97 return Err(AuthFailure::OriginNotAllowed);
98 }
99 }
100
101 let presented = headers
102 .get("authorization")
103 .and_then(|v| v.to_str().ok())
104 .and_then(|h| {
105 let (scheme, rest) = h.split_at_checked(7)?;
107 scheme.eq_ignore_ascii_case("Bearer ").then(|| rest.trim())
108 })
109 .filter(|t| !t.is_empty())
110 .ok_or(AuthFailure::Unauthorized)?;
111
112 let mut matched: Option<McpPrincipal> = None;
115 for (token, principal) in &auth.tokens {
116 let same_len = token.len() == presented.len();
117 let eq = same_len && bool::from(token.as_slice().ct_eq(presented.as_bytes()));
118 if eq && matched.is_none() {
119 matched = Some(principal.clone());
120 }
121 }
122 matched.ok_or(AuthFailure::Unauthorized)
123}
124
125#[allow(dead_code)]
131pub fn authenticate(auth: &McpAuthState, headers: &HeaderMap) -> Result<McpPrincipal, AuthFailure> {
132 let host = headers.get("host").and_then(|v| v.to_str().ok());
133 authenticate_for(auth, headers, host)
134}
135
136pub async fn bearer_middleware(
139 State(auth): State<Arc<McpAuthState>>,
140 mut req: Request<Body>,
141 next: Next,
142) -> Response {
143 let authority = req
144 .headers()
145 .get("host")
146 .and_then(|v| v.to_str().ok())
147 .map(str::to_owned)
148 .or_else(|| req.uri().authority().map(|a| a.as_str().to_owned()));
149 match authenticate_for(&auth, req.headers(), authority.as_deref()) {
150 Ok(principal) => {
151 req.extensions_mut().insert(principal);
152 next.run(req).await
153 }
154 Err(AuthFailure::OriginNotAllowed) => (
155 StatusCode::FORBIDDEN,
156 Json(serde_json::json!({"error": "origin_not_allowed"})),
157 )
158 .into_response(),
159 Err(AuthFailure::Unauthorized) => (
160 StatusCode::UNAUTHORIZED,
161 [("www-authenticate", "Bearer realm=\"featherbit-mcp\"")],
162 Json(serde_json::json!({"error": "unauthorized"})),
163 )
164 .into_response(),
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::config::McpConfig;
172 use axum::body::Body;
173 use axum::http::{HeaderMap, HeaderValue, Request, StatusCode};
174 use axum::routing::get;
175 use axum::Router;
176 use tower::ServiceExt;
177
178 const READ: &str = "read-token-0123456789";
179 const WRITE: &str = "write-token-0123456789";
180
181 fn cfg() -> McpConfig {
182 serde_yaml::from_str(&format!(
183 "enabled: true\ntokens:\n - token: {READ}\n scope: read\n name: local\n - token: {WRITE}\n scope: write\nallowed_origins: [\"http://localhost:5173\"]\n"
184 ))
185 .unwrap()
186 }
187
188 fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
189 let mut h = HeaderMap::new();
190 for (k, v) in pairs {
191 h.insert(*k, HeaderValue::from_str(v).unwrap());
192 }
193 h
194 }
195
196 #[test]
197 fn read_token_yields_read_principal_with_name() {
198 let auth = McpAuthState::from_config(&cfg());
199 let p = authenticate(
200 &auth,
201 &headers(&[("authorization", &format!("Bearer {READ}"))]),
202 )
203 .unwrap();
204 assert_eq!(p.scope, McpScope::Read);
205 assert_eq!(p.name.as_deref(), Some("local"));
206 }
207
208 #[test]
209 fn write_token_yields_write_principal_without_name() {
210 let auth = McpAuthState::from_config(&cfg());
211 let p = authenticate(
212 &auth,
213 &headers(&[("authorization", &format!("bearer {WRITE}"))]),
214 )
215 .unwrap();
216 assert_eq!(p.scope, McpScope::Write);
217 assert_eq!(p.name, None);
218 }
219
220 #[test]
221 fn missing_malformed_and_unknown_tokens_are_unauthorized() {
222 let auth = McpAuthState::from_config(&cfg());
223 assert_eq!(
224 authenticate(&auth, &headers(&[])),
225 Err(AuthFailure::Unauthorized)
226 );
227 assert_eq!(
228 authenticate(&auth, &headers(&[("authorization", "Basic dTpw")])),
229 Err(AuthFailure::Unauthorized)
230 );
231 assert_eq!(
232 authenticate(
233 &auth,
234 &headers(&[("authorization", "Bearer nope-nope-nope-nope")])
235 ),
236 Err(AuthFailure::Unauthorized)
237 );
238 assert_eq!(
240 authenticate(
241 &auth,
242 &headers(&[("authorization", "Bearer read-token-012345678")])
243 ),
244 Err(AuthFailure::Unauthorized)
245 );
246 }
247
248 #[test]
249 fn origin_is_checked_before_the_token() {
250 let auth = McpAuthState::from_config(&cfg());
251 let bad = headers(&[
252 ("origin", "http://evil.example"),
253 ("authorization", &format!("Bearer {READ}")),
254 ]);
255 assert_eq!(
256 authenticate(&auth, &bad),
257 Err(AuthFailure::OriginNotAllowed)
258 );
259 let ok = headers(&[
260 ("origin", "http://localhost:5173"),
261 ("authorization", &format!("Bearer {READ}")),
262 ]);
263 assert!(authenticate(&auth, &ok).is_ok());
264 let mut none = cfg();
266 none.allowed_origins.clear();
267 let auth = McpAuthState::from_config(&none);
268 assert_eq!(authenticate(&auth, &ok), Err(AuthFailure::OriginNotAllowed));
269 }
270
271 #[test]
272 fn same_origin_is_accepted_without_an_allow_list() {
273 let mut none = cfg();
274 none.allowed_origins.clear();
275 let auth = McpAuthState::from_config(&none);
276 let ok = headers(&[
277 ("host", "127.0.0.1:19091"),
278 ("origin", "http://127.0.0.1:19091"),
279 ("authorization", &format!("Bearer {READ}")),
280 ]);
281 assert!(
282 authenticate(&auth, &ok).is_ok(),
283 "Origin authority == Host authority"
284 );
285
286 let https = headers(&[
288 ("host", "Gateway.Example:9091"),
289 ("origin", "https://gateway.example:9091"),
290 ("authorization", &format!("Bearer {READ}")),
291 ]);
292 assert!(authenticate(&auth, &https).is_ok());
293
294 let cross = headers(&[
296 ("host", "127.0.0.1:19091"),
297 ("origin", "http://evil.example"),
298 ("authorization", &format!("Bearer {READ}")),
299 ]);
300 assert_eq!(
301 authenticate(&auth, &cross),
302 Err(AuthFailure::OriginNotAllowed)
303 );
304
305 let port = headers(&[
307 ("host", "127.0.0.1:19091"),
308 ("origin", "http://127.0.0.1:5173"),
309 ("authorization", &format!("Bearer {READ}")),
310 ]);
311 assert_eq!(
312 authenticate(&auth, &port),
313 Err(AuthFailure::OriginNotAllowed)
314 );
315
316 let no_host = headers(&[
318 ("origin", "http://127.0.0.1:19091"),
319 ("authorization", &format!("Bearer {READ}")),
320 ]);
321 assert_eq!(
322 authenticate(&auth, &no_host),
323 Err(AuthFailure::OriginNotAllowed)
324 );
325 }
326
327 #[test]
328 fn authenticate_for_uses_the_uri_authority_when_host_is_absent() {
329 let mut none = cfg();
330 none.allowed_origins.clear();
331 let auth = McpAuthState::from_config(&none);
332 let h = headers(&[
333 ("origin", "http://127.0.0.1:19091"),
334 ("authorization", &format!("Bearer {READ}")),
335 ]);
336 assert!(authenticate_for(&auth, &h, Some("127.0.0.1:19091")).is_ok());
337 assert_eq!(
338 authenticate_for(&auth, &h, Some("other.example")),
339 Err(AuthFailure::OriginNotAllowed)
340 );
341 }
342
343 async fn echo_scope(req: Request<Body>) -> String {
344 req.extensions()
345 .get::<McpPrincipal>()
346 .map(|p| p.scope.as_str().to_string())
347 .unwrap_or_else(|| "none".into())
348 }
349
350 fn app() -> Router {
351 Router::new().route("/mcp", get(echo_scope)).route_layer(
352 axum::middleware::from_fn_with_state(
353 Arc::new(McpAuthState::from_config(&cfg())),
354 bearer_middleware,
355 ),
356 )
357 }
358
359 #[tokio::test]
360 async fn middleware_inserts_principal_and_rejects_properly() {
361 let resp = app()
362 .oneshot(
363 Request::get("/mcp")
364 .header("authorization", format!("Bearer {WRITE}"))
365 .body(Body::empty())
366 .unwrap(),
367 )
368 .await
369 .unwrap();
370 assert_eq!(resp.status(), StatusCode::OK);
371 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
372 .await
373 .unwrap();
374 assert_eq!(&body[..], b"write");
375
376 let resp = app()
377 .oneshot(Request::get("/mcp").body(Body::empty()).unwrap())
378 .await
379 .unwrap();
380 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
381 assert_eq!(
382 resp.headers().get("www-authenticate").unwrap(),
383 "Bearer realm=\"featherbit-mcp\""
384 );
385
386 let resp = app()
387 .oneshot(
388 Request::get("/mcp")
389 .header("origin", "http://evil.example")
390 .header("authorization", format!("Bearer {WRITE}"))
391 .body(Body::empty())
392 .unwrap(),
393 )
394 .await
395 .unwrap();
396 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
397 }
398}