Skip to main content

featherbit/mcp/
auth.rs

1//! Bearer-token authentication for the MCP endpoint.
2//!
3//! Separate from the Admin API's Basic Auth on purpose: an agent gets a
4//! narrower credential (`read` or `write`) that is useless on `/api/*`, and
5//! the Admin credentials are useless here. The token is resolved on **every**
6//! request (never cached on the MCP session) so a client cannot keep a scope
7//! it no longer presents.
8
9use 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/// The identity behind an authenticated MCP request.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct McpPrincipal {
24    /// The token's optional label (`admin.mcp.tokens[].name`), for logs.
25    pub name: Option<String>,
26    /// What this request may do.
27    pub scope: McpScope,
28}
29
30/// Configured tokens, ready for constant-time lookup.
31pub struct McpAuthState {
32    tokens: Vec<(Vec<u8>, McpPrincipal)>,
33    allowed_origins: Vec<String>,
34}
35
36impl McpAuthState {
37    /// Builds the lookup table from validated config.
38    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/// Why a request was refused. Deliberately coarse: callers must not leak
59/// whether a token was unknown, malformed, or absent.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum AuthFailure {
62    /// An `Origin` header was present, not allow-listed, and not same-origin.
63    OriginNotAllowed,
64    /// No usable bearer token.
65    Unauthorized,
66}
67
68/// Extracts `host[:port]` from an `Origin` value such as `https://a.b:9091`.
69fn 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
75/// Resolves the principal for a request from its headers, taking the request
76/// authority (`Host`, or the HTTP/2 `:authority`) from `authority`.
77///
78/// An `Origin` header is accepted when it is allow-listed **or** when its
79/// authority equals the request's own authority (the embedded web UI calling
80/// `/mcp` on whatever hostname it was served from). Cross-site pages fail
81/// both tests. Under DNS rebinding both values name the attacker's domain and
82/// the request reaches the endpoint — but without the bearer token, which a
83/// foreign origin cannot read from this origin's storage, it is still `401`.
84pub 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            // RFC 6750: the scheme is case-insensitive.
106            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    // Compare against every configured token without early exit so timing
113    // does not reveal which entry (if any) matched.
114    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/// [`authenticate_for`] with the authority taken from the `Host` header.
126///
127/// The middleware calls [`authenticate_for`] directly so it can fall back to
128/// the URI authority for HTTP/2; this wrapper is the plain entry point kept
129/// for direct callers (and exercised throughout the tests below).
130#[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
136/// axum middleware for the MCP path: authenticates, then stores the
137/// [`McpPrincipal`] in request extensions for the server handler to read.
138pub 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        // A prefix of a real token must not match.
239        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        // With no allowed origins, ANY Origin header is refused.
265        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        // Case-insensitive host comparison; scheme is ignored.
287        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        // A different authority is still refused.
295        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        // Same host but a different port is a different origin.
306        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        // No Host header and no allow-list: an Origin is still refused.
317        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}