1use std::io::{ErrorKind, Write};
9use std::path::{Path, PathBuf};
10
11#[derive(Clone, Debug)]
13pub enum AuthMode {
14 Enabled(String),
16 Disabled,
18}
19
20#[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
58pub fn is_loopback_host(host: &str) -> bool {
63 matches!(host, "127.0.0.1" | "::1" | "[::1]" | "localhost")
64}
65
66pub 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
78const UNAUTHORIZED_BODY: &str = r#"{"error":"unauthorized"}"#;
80
81pub 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
107fn 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 .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
133pub 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 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 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 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
198pub 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 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
266fn 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")); 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 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 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 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 let dir =
415 std::env::temp_dir().join(format!("dynoxide-unreadable-{}", uuid::Uuid::new_v4()));
416 std::fs::create_dir_all(&dir).unwrap();
417 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}