1use std::io::{ErrorKind, Write};
9use std::path::{Path, PathBuf};
10
11#[derive(Clone)]
13pub enum AuthMode {
14 Enabled(String),
16 Disabled,
18}
19
20impl 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#[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
71pub fn is_loopback_host(host: &str) -> bool {
76 matches!(host, "127.0.0.1" | "::1" | "[::1]" | "localhost")
77}
78
79pub 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
91const UNAUTHORIZED_BODY: &str = r#"{"error":"unauthorized"}"#;
93
94pub 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
120fn 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 .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
146pub 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 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 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 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
211pub 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 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
279fn 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 #[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 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")); 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 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 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 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 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 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}