Skip to main content

dynoxide/mcp/
auth.rs

1//! Bearer-token authentication for the MCP HTTP transport.
2//!
3//! The HTTP transport always enforces a bearer token; there is no loopback
4//! exemption in the request path. When no token is supplied, a loopback bind
5//! auto-generates and persists one. A non-loopback bind must be given an
6//! explicit token. Stdio transport is process-scoped and never uses this.
7
8use std::io::{ErrorKind, Write};
9use std::path::{Path, PathBuf};
10
11/// Resolved authentication mode for the HTTP transport.
12#[derive(Clone, Debug)]
13pub enum AuthMode {
14    /// Enforce this bearer token on every `/mcp` request.
15    Enabled(String),
16    /// Auth disabled. Reachable only via the loopback-only `--no-auth` escape hatch.
17    Disabled,
18}
19
20/// Outcome of resolving the auth mode, including whether a token was just
21/// generated (so the caller can print the one-time first-run guidance) and the
22/// persisted path (for that message). I/O policy stays with the caller.
23#[derive(Clone, Debug)]
24pub struct ResolvedAuth {
25    pub mode: AuthMode,
26    pub first_run: bool,
27    pub token_path: Option<PathBuf>,
28}
29
30#[derive(Debug, thiserror::Error)]
31pub enum AuthError {
32    #[error("--no-auth is only allowed on a loopback bind")]
33    NoAuthRequiresLoopback,
34    #[error("MCP auth token is empty (set --mcp-token/--token or DYNOXIDE_MCP_AUTH_TOKEN)")]
35    EmptyToken,
36    #[error(
37        "a non-loopback MCP bind requires an explicit token (set --mcp-token/--token or DYNOXIDE_MCP_AUTH_TOKEN)"
38    )]
39    NonLoopbackRequiresToken,
40    #[error("could not determine a config directory for the MCP auth token")]
41    NoConfigDir,
42    #[error("cannot create MCP auth token file at {path}: {source}")]
43    CannotCreate {
44        path: PathBuf,
45        #[source]
46        source: std::io::Error,
47    },
48    #[error("MCP auth token file at {path} is unreadable: {source}")]
49    Unreadable {
50        path: PathBuf,
51        #[source]
52        source: std::io::Error,
53    },
54    #[error("MCP auth token file at {path} is empty or corrupt; delete it to regenerate")]
55    CorruptTokenFile { path: PathBuf },
56}
57
58/// Classify a bind host against a strict, closed loopback set. Everything not
59/// in the set — including `0.0.0.0`, other `127.x.x.x`, IPv4-mapped IPv6, and
60/// resolved DNS names — is non-loopback. The literal string is matched; we do
61/// not resolve and re-classify.
62pub fn is_loopback_host(host: &str) -> bool {
63    matches!(host, "127.0.0.1" | "::1" | "[::1]" | "localhost")
64}
65
66/// Constant-time comparison of a presented token against the expected one.
67/// Token length is fixed and not secret, so an early length check is fine.
68pub fn token_matches(expected: &str, presented: &str) -> bool {
69    use subtle::ConstantTimeEq;
70    let a = expected.as_bytes();
71    let b = presented.as_bytes();
72    if a.len() != b.len() {
73        return false;
74    }
75    a.ct_eq(b).into()
76}
77
78/// Identical 401 body for both missing and wrong tokens — no oracle.
79const UNAUTHORIZED_BODY: &str = r#"{"error":"unauthorized"}"#;
80
81/// axum middleware enforcing the bearer token on every request.
82///
83/// Runs *outside* rmcp's Host/Origin checks (it wraps the whole router), so an
84/// unauthenticated caller gets 401 regardless of Host. A caller holding a valid
85/// token who spoofs the Host still hits rmcp's 403 — auth does not replace that
86/// defense-in-depth, it sits in front of it.
87pub async fn enforce(
88    axum::extract::State(mode): axum::extract::State<AuthMode>,
89    req: axum::extract::Request,
90    next: axum::middleware::Next,
91) -> axum::response::Response {
92    match &mode {
93        AuthMode::Disabled => next.run(req).await,
94        AuthMode::Enabled(expected) => {
95            let authorized = bearer_token(req.headers())
96                .map(|presented| token_matches(expected, presented))
97                .unwrap_or(false);
98            if authorized {
99                next.run(req).await
100            } else {
101                unauthorized()
102            }
103        }
104    }
105}
106
107/// Extract the token from an `Authorization: Bearer <token>` header. The scheme
108/// is matched case-insensitively per RFC 7235; absent or non-Bearer → None.
109fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> {
110    let value = headers
111        .get(axum::http::header::AUTHORIZATION)?
112        .to_str()
113        .ok()?;
114    let (scheme, token) = value.split_once([' ', '\t'])?;
115    if !scheme.eq_ignore_ascii_case("bearer") {
116        return None;
117    }
118    Some(token.trim())
119}
120
121fn unauthorized() -> axum::response::Response {
122    use axum::http::{StatusCode, header};
123    axum::response::Response::builder()
124        .status(StatusCode::UNAUTHORIZED)
125        // No `resource_metadata` parameter: it triggers OAuth-discovery bugs in
126        // Claude Code, Cursor, and Copilot CLI.
127        .header(header::WWW_AUTHENTICATE, r#"Bearer realm="dynoxide-mcp""#)
128        .header(header::CONTENT_TYPE, "application/json")
129        .body(axum::body::Body::from(UNAUTHORIZED_BODY))
130        .expect("static 401 response is always valid")
131}
132
133/// Resolve the auth mode from the merged CLI/env token, the no-auth flag, and
134/// whether the bind is loopback. `token_path_override` exists for tests; in
135/// production the per-OS config path is used.
136pub fn resolve_auth(
137    bind_is_loopback: bool,
138    cli_token: Option<String>,
139    no_auth: bool,
140    token_path_override: Option<PathBuf>,
141) -> Result<ResolvedAuth, AuthError> {
142    if no_auth {
143        if !bind_is_loopback {
144            return Err(AuthError::NoAuthRequiresLoopback);
145        }
146        // Never reads, writes, or creates the token file.
147        return Ok(ResolvedAuth {
148            mode: AuthMode::Disabled,
149            first_run: false,
150            token_path: None,
151        });
152    }
153
154    if let Some(token) = cli_token {
155        // Trim to match the presented-token side (bearer_token trims) and the
156        // persisted-file path; otherwise a token supplied with stray whitespace
157        // would never match.
158        let token = token.trim();
159        if token.is_empty() {
160            return Err(AuthError::EmptyToken);
161        }
162        return Ok(ResolvedAuth {
163            mode: AuthMode::Enabled(token.to_string()),
164            first_run: false,
165            token_path: None,
166        });
167    }
168
169    if !bind_is_loopback {
170        return Err(AuthError::NonLoopbackRequiresToken);
171    }
172
173    let path = match token_path_override {
174        Some(p) => p,
175        None => default_token_path()?,
176    };
177
178    // Atomic create (O_EXCL): exactly one of two concurrent first-runs wins the
179    // create; the loser observes AlreadyExists and reads the winner's token.
180    match create_new_token_file(&path) {
181        Ok(token) => Ok(ResolvedAuth {
182            mode: AuthMode::Enabled(token),
183            first_run: true,
184            token_path: Some(path),
185        }),
186        Err(CreateError::AlreadyExists) => {
187            let token = read_token_file(&path)?;
188            Ok(ResolvedAuth {
189                mode: AuthMode::Enabled(token),
190                first_run: false,
191                token_path: Some(path),
192            })
193        }
194        Err(CreateError::Io(source)) => Err(AuthError::CannotCreate { path, source }),
195    }
196}
197
198/// One-time guidance printed to stderr when a token is first generated.
199pub fn first_run_message(url: &str, token: &str, path: &Path) -> String {
200    format!(
201        "Generated an MCP auth token and saved it to {path}.\n\
202         Add it to your MCP client config — e.g. Claude Code .mcp.json:\n\
203         \n\
204         \x20\x20\"dynoxide\": {{\n\
205         \x20\x20\x20\x20\"type\": \"http\",\n\
206         \x20\x20\x20\x20\"url\": \"{url}\",\n\
207         \x20\x20\x20\x20\"headers\": {{ \"Authorization\": \"Bearer {token}\" }}\n\
208         \x20\x20}}\n\
209         \n\
210         This token persists across restarts. Pin it with DYNOXIDE_MCP_AUTH_TOKEN or --mcp-token.",
211        path = path.display(),
212    )
213}
214
215fn default_token_path() -> Result<PathBuf, AuthError> {
216    let dirs = directories::ProjectDirs::from("", "", "dynoxide").ok_or(AuthError::NoConfigDir)?;
217    Ok(dirs.config_dir().join("mcp-token"))
218}
219
220enum CreateError {
221    AlreadyExists,
222    Io(std::io::Error),
223}
224
225fn create_new_token_file(path: &Path) -> Result<String, CreateError> {
226    if let Some(parent) = path.parent() {
227        std::fs::create_dir_all(parent).map_err(CreateError::Io)?;
228    }
229
230    let mut opts = std::fs::OpenOptions::new();
231    opts.write(true).create_new(true);
232    #[cfg(unix)]
233    {
234        use std::os::unix::fs::OpenOptionsExt;
235        opts.mode(0o600);
236    }
237
238    match opts.open(path) {
239        Ok(mut file) => {
240            let token = generate_token();
241            file.write_all(token.as_bytes()).map_err(CreateError::Io)?;
242            // Durably flush before returning so a process that lost the create
243            // race reads a complete token rather than a partial/empty file.
244            file.sync_all().map_err(CreateError::Io)?;
245            Ok(token)
246        }
247        Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(CreateError::AlreadyExists),
248        Err(e) => Err(CreateError::Io(e)),
249    }
250}
251
252fn read_token_file(path: &Path) -> Result<String, AuthError> {
253    let raw = std::fs::read_to_string(path).map_err(|source| AuthError::Unreadable {
254        path: path.to_path_buf(),
255        source,
256    })?;
257    let token = raw.trim().to_string();
258    if token.is_empty() {
259        return Err(AuthError::CorruptTokenFile {
260            path: path.to_path_buf(),
261        });
262    }
263    Ok(token)
264}
265
266/// Generate a high-entropy, URL-safe token. Randomness comes from two v4 UUIDs
267/// (CSPRNG-backed, 244 bits combined) to avoid a direct dependency on a
268/// specific `getrandom` major; 244 bits is well above the bearer-token bar.
269fn generate_token() -> String {
270    use base64::Engine;
271    let mut bytes = [0u8; 32];
272    bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
273    bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
274    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn temp_token_path() -> PathBuf {
282        let dir =
283            std::env::temp_dir().join(format!("dynoxide-mcp-token-test-{}", uuid::Uuid::new_v4()));
284        dir.join("mcp-token")
285    }
286
287    #[test]
288    fn loopback_set_is_closed() {
289        assert!(is_loopback_host("127.0.0.1"));
290        assert!(is_loopback_host("::1"));
291        assert!(is_loopback_host("[::1]"));
292        assert!(is_loopback_host("localhost"));
293        assert!(!is_loopback_host("0.0.0.0"));
294        assert!(!is_loopback_host("127.0.0.2"));
295        assert!(!is_loopback_host("::ffff:127.0.0.1"));
296        assert!(!is_loopback_host("example.com"));
297    }
298
299    #[test]
300    fn token_matches_is_correct() {
301        assert!(token_matches("abc123", "abc123"));
302        assert!(!token_matches("abc123", "abc124"));
303        assert!(!token_matches("abc123", "abc12")); // length mismatch
304        assert!(!token_matches("abc123", "abc1234"));
305    }
306
307    #[test]
308    fn first_run_generates_persists_and_signals() {
309        let path = temp_token_path();
310        let resolved = resolve_auth(true, None, false, Some(path.clone())).unwrap();
311        assert!(resolved.first_run);
312        assert_eq!(resolved.token_path.as_deref(), Some(path.as_path()));
313        let token = match resolved.mode {
314            AuthMode::Enabled(t) => t,
315            AuthMode::Disabled => panic!("expected Enabled"),
316        };
317        assert!(!token.is_empty());
318        // Persisted with exactly the generated token.
319        assert_eq!(std::fs::read_to_string(&path).unwrap(), token);
320        let _ = std::fs::remove_dir_all(path.parent().unwrap());
321    }
322
323    #[cfg(unix)]
324    #[test]
325    fn persisted_file_is_0600() {
326        use std::os::unix::fs::PermissionsExt;
327        let path = temp_token_path();
328        resolve_auth(true, None, false, Some(path.clone())).unwrap();
329        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
330        assert_eq!(mode & 0o777, 0o600);
331        let _ = std::fs::remove_dir_all(path.parent().unwrap());
332    }
333
334    #[test]
335    fn second_run_reads_existing_silently() {
336        let path = temp_token_path();
337        let first = resolve_auth(true, None, false, Some(path.clone())).unwrap();
338        let first_token = match first.mode {
339            AuthMode::Enabled(t) => t,
340            AuthMode::Disabled => panic!("expected Enabled"),
341        };
342        let second = resolve_auth(true, None, false, Some(path.clone())).unwrap();
343        assert!(!second.first_run);
344        match second.mode {
345            AuthMode::Enabled(t) => assert_eq!(t, first_token),
346            AuthMode::Disabled => panic!("expected Enabled"),
347        }
348        let _ = std::fs::remove_dir_all(path.parent().unwrap());
349    }
350
351    #[test]
352    fn explicit_token_wins_over_file() {
353        let path = temp_token_path();
354        let resolved = resolve_auth(
355            true,
356            Some("supplied".to_string()),
357            false,
358            Some(path.clone()),
359        )
360        .unwrap();
361        assert!(!resolved.first_run);
362        assert!(resolved.token_path.is_none());
363        match resolved.mode {
364            AuthMode::Enabled(t) => assert_eq!(t, "supplied"),
365            AuthMode::Disabled => panic!("expected Enabled"),
366        }
367        // File must not have been created.
368        assert!(!path.exists());
369    }
370
371    #[test]
372    fn empty_token_is_error() {
373        assert!(matches!(
374            resolve_auth(true, Some(String::new()), false, None),
375            Err(AuthError::EmptyToken)
376        ));
377        assert!(matches!(
378            resolve_auth(true, Some("   ".to_string()), false, None),
379            Err(AuthError::EmptyToken)
380        ));
381    }
382
383    #[test]
384    fn non_loopback_without_token_is_error() {
385        let path = temp_token_path();
386        assert!(matches!(
387            resolve_auth(false, None, false, Some(path.clone())),
388            Err(AuthError::NonLoopbackRequiresToken)
389        ));
390        // No file touched.
391        assert!(!path.exists());
392    }
393
394    #[test]
395    fn no_auth_loopback_disables_and_skips_file() {
396        let path = temp_token_path();
397        let resolved = resolve_auth(true, None, true, Some(path.clone())).unwrap();
398        assert!(matches!(resolved.mode, AuthMode::Disabled));
399        assert!(!resolved.first_run);
400        assert!(!path.exists());
401    }
402
403    #[test]
404    fn no_auth_non_loopback_is_error() {
405        assert!(matches!(
406            resolve_auth(false, None, true, None),
407            Err(AuthError::NoAuthRequiresLoopback)
408        ));
409    }
410
411    #[test]
412    fn unreadable_file_errors_without_regenerating() {
413        // A directory where the token file is expected is unreadable as a file.
414        let dir =
415            std::env::temp_dir().join(format!("dynoxide-unreadable-{}", uuid::Uuid::new_v4()));
416        std::fs::create_dir_all(&dir).unwrap();
417        // path points at a directory, so create_new fails with AlreadyExists,
418        // then read_to_string fails -> Unreadable.
419        let result = resolve_auth(true, None, false, Some(dir.clone()));
420        assert!(matches!(result, Err(AuthError::Unreadable { .. })));
421        let _ = std::fs::remove_dir_all(&dir);
422    }
423
424    #[test]
425    fn corrupt_empty_file_errors() {
426        let path = temp_token_path();
427        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
428        std::fs::write(&path, "   \n").unwrap();
429        let result = resolve_auth(true, None, false, Some(path.clone()));
430        assert!(matches!(result, Err(AuthError::CorruptTokenFile { .. })));
431        let _ = std::fs::remove_dir_all(path.parent().unwrap());
432    }
433}