1use crate::error::{Result, YnabMcpError};
2use keyring::{Entry, Error as KeyringError};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::env;
6use std::fmt;
7use tracing::{debug, info, warn};
8use zeroize::Zeroize;
9
10const SERVICE_NAME: &str = "ynab-mcp";
12
13const USER_NAME: &str = "api-token";
15
16#[derive(Debug, Clone, Copy)]
18pub struct SecretsConfig {
19 pub use_keyring: bool,
21 pub auto_migrate: bool,
23 pub require_secure_storage: bool,
25}
26
27impl Default for SecretsConfig {
28 fn default() -> Self {
29 Self {
30 use_keyring: true,
31 auto_migrate: true,
32 require_secure_storage: false, }
34 }
35}
36
37#[derive(Clone, Zeroize)]
39pub struct YnabApiToken {
40 #[zeroize(skip)]
41 inner: SecretString,
42}
43
44impl YnabApiToken {
45 pub fn new(token: String) -> Self {
47 Self {
48 inner: SecretString::new(token.into()),
49 }
50 }
51
52 pub fn expose_secret(&self) -> &str {
54 self.inner.expose_secret()
55 }
56
57 pub fn is_empty(&self) -> bool {
59 self.inner.expose_secret().is_empty()
60 }
61
62 pub fn validate(&self) -> Result<()> {
64 let token = self.inner.expose_secret();
65
66 if token.is_empty() {
67 return Err(YnabMcpError::authentication(
68 "YNAB API token is missing or empty",
69 "Set your YNAB API token using: 1) Environment variable YNAB_API_TOKEN, 2) Run 'ynab-mcp store-token' to store securely in keyring, or 3) Get a token from https://app.youneedabudget.com/settings/developer"
70 ));
71 }
72
73 if token.len() < 10 {
75 return Err(YnabMcpError::validation(
76 &format!("YNAB API token is too short ({} characters)", token.len()),
77 "ynab_api_token",
78 "at least 10 characters (typically 43+ characters for real YNAB tokens)",
79 "Check your token at https://app.youneedabudget.com/settings/developer and ensure you copied the complete token"
80 ));
81 }
82
83 let is_test_token = Self::is_test_token(token);
85 if is_test_token {
86 let env_block_test = std::env::var("YNAB_BLOCK_TEST_TOKENS")
87 .map(|v| v.to_lowercase() == "true" || v == "1")
88 .unwrap_or(false);
89
90 if env_block_test {
91 return Err(YnabMcpError::authentication(
92 "Test/placeholder token detected and blocked in production mode",
93 "Replace with a real YNAB API token from https://app.youneedabudget.com/settings/developer or set YNAB_BLOCK_TEST_TOKENS=false for testing"
94 ));
95 } else {
96 warn!("Token appears to be a test/placeholder token - set YNAB_BLOCK_TEST_TOKENS=true to block in production");
97 }
98 }
99
100 Ok(())
101 }
102
103 pub fn is_test_token(token: &str) -> bool {
105 let test_patterns = [
106 "test",
107 "dummy",
108 "placeholder",
109 "example",
110 "fake",
111 "demo",
112 "sample",
113 "mock",
114 "ynab_api_token",
115 "your_token_here",
116 "xxx",
117 ];
118 let lower_token = token.to_lowercase();
119
120 for pattern in &test_patterns {
122 if lower_token.contains(pattern) {
123 return true;
124 }
125 }
126
127 if token.len() > 4 {
129 if let Some(first_char) = token.chars().next() {
130 if token.chars().all(|c| c == first_char) {
131 return true;
132 }
133 }
134 }
135
136 if token == "abcdefghijklmnopqrstuvwxyz" || token == "123456789" {
138 return true;
139 }
140
141 false
142 }
143}
144
145impl fmt::Debug for YnabApiToken {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.debug_struct("YnabApiToken")
148 .field("inner", &"[REDACTED]")
149 .finish()
150 }
151}
152
153impl fmt::Display for YnabApiToken {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(f, "[REDACTED TOKEN]")
156 }
157}
158
159impl Serialize for YnabApiToken {
161 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
162 where
163 S: Serializer,
164 {
165 serializer.serialize_str("[REDACTED]")
166 }
167}
168
169impl<'de> Deserialize<'de> for YnabApiToken {
170 fn deserialize<D>(_deserializer: D) -> std::result::Result<Self, D::Error>
171 where
172 D: Deserializer<'de>,
173 {
174 Err(serde::de::Error::custom(
176 "YnabApiToken cannot be deserialized - must be constructed explicitly",
177 ))
178 }
179}
180
181pub struct SecretsManager {
183 config: SecretsConfig,
184 keyring_entry: Option<Entry>,
185}
186
187impl SecretsManager {
188 pub fn new(config: SecretsConfig) -> Result<Self> {
190 let keyring_entry = if config.use_keyring {
191 match Entry::new(SERVICE_NAME, USER_NAME) {
192 Ok(entry) => {
193 info!("Keyring available: {}", Self::get_keyring_info());
194 Some(entry)
195 }
196 Err(e) => {
197 if config.require_secure_storage {
198 return Err(YnabMcpError::config(
199 &format!("Keyring required but unavailable: {}", e),
200 "Install and configure a keyring service on your system, or set REQUIRE_SECURE_STORAGE=false to allow environment variable storage"
201 ));
202 } else {
203 warn!(
204 "Keyring unavailable, falling back to environment variables: {}",
205 e
206 );
207 None
208 }
209 }
210 }
211 } else {
212 debug!("Keyring disabled by configuration");
213 None
214 };
215
216 Ok(Self {
217 config,
218 keyring_entry,
219 })
220 }
221
222 fn get_keyring_info() -> String {
224 if cfg!(target_os = "macos") {
226 "macOS Keychain".to_string()
227 } else if cfg!(target_os = "windows") {
228 "Windows Credential Manager".to_string()
229 } else if cfg!(target_os = "linux") {
230 "Linux Kernel Keyring (keyutils)".to_string()
231 } else {
232 "Generic keyring backend".to_string()
233 }
234 }
235
236 pub fn load_token(&self) -> Result<YnabApiToken> {
238 if let Some(ref entry) = self.keyring_entry {
240 match entry.get_password() {
241 Ok(token) => {
242 debug!("Token loaded from keyring");
243 return Ok(YnabApiToken::new(token));
244 }
245 Err(KeyringError::NoEntry) => {
246 debug!("No token found in keyring");
247 }
249 Err(e) => {
250 warn!("Failed to read from keyring: {}", e);
251 if self.config.require_secure_storage {
252 return Err(YnabMcpError::authentication(
253 &format!("Failed to read from keyring: {}", e),
254 "Check that keyring service is running and accessible. On Linux, ensure gnome-keyring or kwallet is available. Set REQUIRE_SECURE_STORAGE=false to use environment variables instead."
255 ));
256 }
257 }
259 }
260 }
261
262 let token = env::var("YNAB_API_TOKEN")
264 .or_else(|_| env::var("YNAB_ACCESS_TOKEN"))
265 .map_err(|_| YnabMcpError::authentication(
266 "YNAB API token not found",
267 "Set your YNAB API token using environment variable YNAB_API_TOKEN or run 'ynab-mcp store-token' to store it securely. Get a token from https://app.youneedabudget.com/settings/developer"
268 ))?;
269
270 debug!("Token loaded from environment variables");
271
272 let ynab_token = YnabApiToken::new(token);
273
274 if self.config.auto_migrate && self.keyring_entry.is_some() {
276 if let Err(e) = self.store_token(&ynab_token) {
277 warn!(
278 operation = "token_migration",
279 result = "failed",
280 error = %e,
281 "Failed to migrate token to keyring"
282 );
283 } else {
284 info!(
285 operation = "token_migration",
286 result = "success",
287 "Token automatically migrated to keyring for enhanced security"
288 );
289 info!(
290 operation = "token_migration",
291 cleanup_hint = "env_var_removal",
292 "You can now remove YNAB_API_TOKEN from environment variables"
293 );
294 }
295 }
296
297 Ok(ynab_token)
298 }
299
300 pub fn store_token(&self, token: &YnabApiToken) -> Result<()> {
302 let entry = self
303 .keyring_entry
304 .as_ref()
305 .ok_or_else(|| YnabMcpError::config(
306 "Keyring is not available on this system",
307 "Install and configure a keyring service (gnome-keyring, kwallet, or Windows Credential Manager) or set USE_KEYRING=false to use environment variables"
308 ))?;
309
310 entry
311 .set_password(token.expose_secret())
312 .map_err(|e| YnabMcpError::authentication(
313 &format!("Failed to write to keyring: {}", e),
314 "Ensure keyring service is running and has write permissions. Check that the keyring is unlocked and accessible."
315 ))?;
316
317 info!("Token stored in keyring successfully");
318 Ok(())
319 }
320
321 pub fn delete_token(&self) -> Result<()> {
323 let entry = self
324 .keyring_entry
325 .as_ref()
326 .ok_or_else(|| YnabMcpError::config(
327 "Keyring is not available on this system",
328 "Install and configure a keyring service (gnome-keyring, kwallet, or Windows Credential Manager) or set USE_KEYRING=false to use environment variables"
329 ))?;
330
331 match entry.delete_credential() {
332 Ok(()) => {
333 info!("Token deleted from keyring");
334 Ok(())
335 }
336 Err(KeyringError::NoEntry) => {
337 debug!("No token found in keyring to delete");
338 Ok(())
339 }
340 Err(e) => Err(YnabMcpError::authentication(
341 &format!("Failed to delete token from keyring: {}", e),
342 "Check that keyring service is accessible and the token exists. The keyring may be locked or the service may be unavailable."
343 )),
344 }
345 }
346
347 pub fn is_keyring_available(&self) -> bool {
349 self.keyring_entry.is_some()
350 }
351
352 pub fn get_storage_method(&self) -> StorageMethod {
354 if self.keyring_entry.is_some() {
355 if let Some(ref entry) = self.keyring_entry {
357 match entry.get_password() {
358 Ok(_) => StorageMethod::Keyring,
359 Err(_) => StorageMethod::EnvironmentVariable,
360 }
361 } else {
362 StorageMethod::EnvironmentVariable
363 }
364 } else {
365 StorageMethod::EnvironmentVariable
366 }
367 }
368
369 pub fn setup_guidance(&self) -> Vec<String> {
371 let mut guidance = Vec::new();
372
373 if !self.is_keyring_available() {
374 guidance.push("⚠️ Keyring unavailable - using environment variables".to_string());
375 guidance.push(
376 " For enhanced security, ensure your system supports keyring storage".to_string(),
377 );
378 } else if self.get_storage_method() == StorageMethod::EnvironmentVariable {
379 guidance.push(
380 "💡 You can enhance security by storing your token in the system keyring:"
381 .to_string(),
382 );
383 guidance.push(" 1. Run: ynab-mcp --store-token".to_string());
384 guidance.push(" 2. Remove YNAB_API_TOKEN from environment variables".to_string());
385 } else {
386 guidance.push("✅ Token is securely stored in system keyring".to_string());
387 }
388
389 guidance
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
395pub enum StorageMethod {
396 Keyring,
398 EnvironmentVariable,
400}
401
402impl fmt::Display for StorageMethod {
403 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404 match self {
405 StorageMethod::Keyring => write!(f, "System Keyring"),
406 StorageMethod::EnvironmentVariable => write!(f, "Environment Variables"),
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_token_creation() {
417 let token = YnabApiToken::new("test-token-123".to_string());
418 assert_eq!(token.expose_secret(), "test-token-123");
419 assert!(!token.is_empty());
420 }
421
422 #[test]
423 fn test_empty_token() {
424 let token = YnabApiToken::new("".to_string());
425 assert!(token.is_empty());
426 assert!(token.validate().is_err());
427 }
428
429 #[test]
430 fn test_token_validation() {
431 let valid_token = YnabApiToken::new("sk_1234567890abcdef".to_string());
432 assert!(valid_token.validate().is_ok());
433
434 let short_token = YnabApiToken::new("short".to_string());
435 assert!(short_token.validate().is_err());
436 }
437
438 #[test]
439 fn test_token_debug_format() {
440 let token = YnabApiToken::new("secret-token".to_string());
441 let debug_str = format!("{:?}", token);
442 assert!(debug_str.contains("[REDACTED]"));
443 assert!(!debug_str.contains("secret-token"));
444 }
445
446 #[test]
447 fn test_token_display_format() {
448 let token = YnabApiToken::new("secret-token".to_string());
449 let display_str = format!("{}", token);
450 assert_eq!(display_str, "[REDACTED TOKEN]");
451 }
452
453 #[test]
454 fn test_token_serialization() {
455 let token = YnabApiToken::new("secret-token".to_string());
456 let json = serde_json::to_string(&token).unwrap();
457 assert_eq!(json, "\"[REDACTED]\"");
458 }
459
460 #[test]
461 fn test_secrets_config_default() {
462 let config = SecretsConfig::default();
463 assert!(config.use_keyring);
464 assert!(config.auto_migrate);
465 assert!(!config.require_secure_storage);
466 }
467
468 #[test]
469 fn test_storage_method_display() {
470 assert_eq!(format!("{}", StorageMethod::Keyring), "System Keyring");
471 assert_eq!(
472 format!("{}", StorageMethod::EnvironmentVariable),
473 "Environment Variables"
474 );
475 }
476
477 #[test]
478 fn test_is_test_token() {
479 assert!(YnabApiToken::is_test_token("test-token"));
481 assert!(YnabApiToken::is_test_token("dummy_token"));
482 assert!(YnabApiToken::is_test_token("PLACEHOLDER"));
483 assert!(YnabApiToken::is_test_token("ExampleToken"));
484 assert!(YnabApiToken::is_test_token("fake-key"));
485 assert!(YnabApiToken::is_test_token("demo123"));
486 assert!(YnabApiToken::is_test_token("sample_api_key"));
487 assert!(YnabApiToken::is_test_token("mock-token"));
488 assert!(YnabApiToken::is_test_token("ynab_api_token"));
489 assert!(YnabApiToken::is_test_token("your_token_here"));
490 assert!(YnabApiToken::is_test_token("xxxxx"));
491
492 assert!(YnabApiToken::is_test_token("aaaaaaa"));
494 assert!(YnabApiToken::is_test_token("1111111"));
495 assert!(YnabApiToken::is_test_token("00000"));
496
497 assert!(YnabApiToken::is_test_token("abcdefghijklmnopqrstuvwxyz"));
499 assert!(YnabApiToken::is_test_token("123456789"));
500
501 assert!(!YnabApiToken::is_test_token("sk_1a2b3c4d5e6f7890abcdef"));
503 assert!(!YnabApiToken::is_test_token(
504 "real_api_key_with_actual_randomness"
505 ));
506 assert!(!YnabApiToken::is_test_token("abc123xyz789"));
507 }
508
509 #[test]
510 fn test_test_token_validation_blocked() {
511 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "true");
512
513 let test_token = YnabApiToken::new("test-token".to_string());
514 let result = test_token.validate();
515 assert!(result.is_err());
516 assert!(result
517 .unwrap_err()
518 .to_string()
519 .contains("Test/placeholder token detected"));
520
521 std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
522 }
523
524 #[test]
525 fn test_test_token_validation_allowed() {
526 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "false");
527
528 let test_token = YnabApiToken::new("test-token-long-enough".to_string());
529 let result = test_token.validate();
530 assert!(result.is_ok()); std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
533 }
534
535 #[test]
536 fn test_test_token_validation_no_env() {
537 std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
538
539 let test_token = YnabApiToken::new("test-token-long-enough".to_string());
540 let result = test_token.validate();
541 assert!(result.is_ok()); }
543
544 #[test]
545 fn test_secrets_config_custom() {
546 let config = SecretsConfig {
547 use_keyring: false,
548 auto_migrate: false,
549 require_secure_storage: true,
550 };
551
552 assert!(!config.use_keyring);
553 assert!(!config.auto_migrate);
554 assert!(config.require_secure_storage);
555 }
556
557 #[test]
558 fn test_get_keyring_info() {
559 let info = SecretsManager::get_keyring_info();
560 assert!(!info.is_empty());
561
562 if cfg!(target_os = "macos") {
564 assert!(info.contains("macOS Keychain"));
565 } else if cfg!(target_os = "windows") {
566 assert!(info.contains("Windows Credential Manager"));
567 } else if cfg!(target_os = "linux") {
568 assert!(info.contains("Linux Kernel Keyring"));
569 } else {
570 assert!(info.contains("Generic keyring backend"));
571 }
572 }
573
574 #[test]
575 fn test_secrets_manager_creation_with_keyring_disabled() {
576 let config = SecretsConfig {
577 use_keyring: false,
578 auto_migrate: false,
579 require_secure_storage: false,
580 };
581
582 let manager = SecretsManager::new(config);
583 assert!(manager.is_ok());
584 let manager = manager.unwrap();
585 assert!(!manager.is_keyring_available());
586 assert_eq!(
587 manager.get_storage_method(),
588 StorageMethod::EnvironmentVariable
589 );
590 }
591
592 #[test]
593 fn test_secrets_manager_creation_with_required_secure_storage() {
594 let config = SecretsConfig {
595 use_keyring: true,
596 auto_migrate: false,
597 require_secure_storage: true,
598 };
599
600 let manager_result = SecretsManager::new(config);
602 let _ = manager_result;
604 }
605
606 #[test]
607 fn test_token_deserialization_blocked() {
608 let json = "\"some-token\"";
610 let result: std::result::Result<YnabApiToken, _> = serde_json::from_str(json);
611 assert!(result.is_err());
612 assert!(result
613 .unwrap_err()
614 .to_string()
615 .contains("cannot be deserialized"));
616 }
617
618 #[test]
619 fn test_token_short_validation_error() {
620 let short_token = YnabApiToken::new("short".to_string());
621 let result = short_token.validate();
622 assert!(result.is_err());
623 let error_msg = result.unwrap_err().to_string();
624 assert!(error_msg.contains("too short"));
625 assert!(error_msg.contains("5 characters"));
626 }
627
628 #[test]
629 fn test_token_empty_validation_error() {
630 let empty_token = YnabApiToken::new("".to_string());
631 let result = empty_token.validate();
632 assert!(result.is_err());
633 let error_msg = result.unwrap_err().to_string();
634 assert!(error_msg.contains("missing or empty"));
635 }
636
637 #[test]
638 fn test_storage_method_equality() {
639 assert_eq!(StorageMethod::Keyring, StorageMethod::Keyring);
640 assert_eq!(
641 StorageMethod::EnvironmentVariable,
642 StorageMethod::EnvironmentVariable
643 );
644 assert_ne!(StorageMethod::Keyring, StorageMethod::EnvironmentVariable);
645 }
646
647 #[test]
648 fn test_secrets_config_debug() {
649 let config = SecretsConfig::default();
650 let debug_str = format!("{:?}", config);
651 assert!(debug_str.contains("SecretsConfig"));
652 assert!(debug_str.contains("use_keyring"));
653 }
654
655 #[test]
656 fn test_token_clone() {
657 let token = YnabApiToken::new("test-token".to_string());
658 let cloned = token.clone();
659 assert_eq!(token.expose_secret(), cloned.expose_secret());
660 }
661
662 #[test]
663 fn test_repeated_char_detection_edge_cases() {
664 assert!(!YnabApiToken::is_test_token("aa"));
666 assert!(!YnabApiToken::is_test_token("aaa"));
667 assert!(!YnabApiToken::is_test_token("aaaa"));
668
669 assert!(YnabApiToken::is_test_token("aaaaa"));
671
672 assert!(!YnabApiToken::is_test_token(""));
674 }
675
676 #[test]
677 fn test_env_var_parsing_edge_cases() {
678 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "TRUE");
680 let token = YnabApiToken::new("test-token-long".to_string());
681 assert!(token.validate().is_err());
682
683 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "1");
684 assert!(token.validate().is_err());
685
686 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "false");
687 assert!(token.validate().is_ok());
688
689 std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "random_value");
690 assert!(token.validate().is_ok());
691
692 std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
693 }
694}