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