runbeam_sdk/runbeam_api/
token_storage.rs

1use crate::storage::{EncryptedFilesystemStorage, KeyringStorage, StorageBackend, StorageError};
2use chrono::{DateTime, Utc};
3use serde::{de::DeserializeOwned, Deserialize, Serialize};
4
5/// Storage backend types
6enum StorageBackendType {
7    Keyring(KeyringStorage),
8    Encrypted(EncryptedFilesystemStorage),
9}
10
11/// Get or initialize the storage backend
12///
13/// This function attempts to use KeyringStorage first, falling back to
14/// EncryptedFilesystemStorage if keyring is unavailable.
15///
16/// # Arguments
17///
18/// * `instance_id` - Unique identifier for this application instance
19/// * `token_path` - Storage path for existence check (e.g., "runbeam/auth.json")
20async fn get_storage_backend(
21    instance_id: &str,
22    token_path: &str,
23) -> Result<StorageBackendType, StorageError> {
24    // Check if keyring is disabled (e.g., in tests)
25    let keyring_disabled = std::env::var("RUNBEAM_DISABLE_KEYRING").is_ok();
26
27    // Try keyring first (unless disabled)
28    let keyring_works = if keyring_disabled {
29        tracing::debug!("Keyring disabled via RUNBEAM_DISABLE_KEYRING environment variable");
30        false
31    } else {
32        let keyring = KeyringStorage::new("runbeam");
33        let path = token_path.to_string();
34
35        // Test if keyring is available by attempting to check if token path exists
36        let test_result = tokio::task::spawn_blocking(move || keyring.exists_str(&path)).await;
37
38        match test_result {
39            Ok(_) => {
40                // Keyring operations succeeded
41                tracing::debug!(
42                    "Keyring storage is available, using OS keychain for secure token storage"
43                );
44                true
45            }
46            Err(e) => {
47                tracing::debug!(
48                    "Keyring storage is unavailable ({}), falling back to encrypted filesystem storage",
49                    e
50                );
51                false
52            }
53        }
54    };
55
56    if keyring_works {
57        Ok(StorageBackendType::Keyring(KeyringStorage::new("runbeam")))
58    } else {
59        tracing::debug!(
60            "Using encrypted filesystem storage at ~/.runbeam/{} (RUNBEAM_ENCRYPTION_KEY environment variable can override key)",
61            instance_id
62        );
63        let encrypted = EncryptedFilesystemStorage::new_with_instance(instance_id)
64            .await
65            .map_err(|e| {
66                tracing::error!("Failed to initialize encrypted storage: {}", e);
67                e
68            })?;
69        Ok(StorageBackendType::Encrypted(encrypted))
70    }
71}
72
73/// Machine-scoped token for Runbeam Cloud API authentication
74///
75/// This token is issued by Runbeam Cloud and allows the gateway to make
76/// autonomous API calls without user intervention. It has a 30-day expiry.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct MachineToken {
79    /// The machine token string
80    pub machine_token: String,
81    /// When the token expires (ISO 8601 format)
82    pub expires_at: String,
83    /// Gateway ID
84    pub gateway_id: String,
85    /// Gateway code (instance ID)
86    pub gateway_code: String,
87    /// Token abilities/permissions
88    #[serde(default)]
89    pub abilities: Vec<String>,
90    /// When this token was issued/stored (ISO 8601 format)
91    pub issued_at: String,
92}
93
94impl MachineToken {
95    /// Create a new machine token
96    pub fn new(
97        machine_token: String,
98        expires_at: String,
99        gateway_id: String,
100        gateway_code: String,
101        abilities: Vec<String>,
102    ) -> Self {
103        let issued_at = Utc::now().to_rfc3339();
104
105        Self {
106            machine_token,
107            expires_at,
108            gateway_id,
109            gateway_code,
110            abilities,
111            issued_at,
112        }
113    }
114
115    /// Check if the token has expired
116    pub fn is_expired(&self) -> bool {
117        // Parse the expiry timestamp
118        match DateTime::parse_from_rfc3339(&self.expires_at) {
119            Ok(expiry) => {
120                let now = Utc::now();
121                expiry.with_timezone(&Utc) < now
122            }
123            Err(e) => {
124                tracing::warn!("Failed to parse token expiry date: {}", e);
125                // If we can't parse the date, consider it expired for safety
126                true
127            }
128        }
129    }
130
131    /// Check if the token is still valid (not expired)
132    pub fn is_valid(&self) -> bool {
133        !self.is_expired()
134    }
135}
136
137// ============================================================================
138// Generic Token Storage Functions
139// ============================================================================
140
141/// Save any token using automatic secure storage selection (generic)
142///
143/// This function automatically selects the most secure available storage:
144/// 1. **Keyring** (OS keychain) if available
145/// 2. **Encrypted filesystem** if keyring is unavailable
146///
147/// # Type Parameters
148///
149/// * `T` - Any type that implements `Serialize`
150///
151/// # Arguments
152///
153/// * `instance_id` - Unique identifier for this application instance (e.g., "harmony", "runbeam-cli")
154/// * `token_type` - Token type identifier (e.g., "auth", "user_auth", "custom")
155/// * `token` - The token to save
156///
157/// # Returns
158///
159/// Returns `Ok(())` if the token was saved successfully, or `Err(StorageError)`
160/// if the operation failed.
161///
162/// # Examples
163///
164/// ```no_run
165/// use runbeam_sdk::{save_token, MachineToken, UserToken, UserInfo};
166///
167/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
168/// // Save machine token
169/// let machine_token = MachineToken::new(
170///     "token123".to_string(),
171///     "2024-12-01T00:00:00Z".to_string(),
172///     "gateway-1".to_string(),
173///     "code-1".to_string(),
174///     vec!["read".to_string()],
175/// );
176/// save_token("harmony", "auth", &machine_token).await?;
177///
178/// // Save user token
179/// let user_info = UserInfo {
180///     id: "user-1".to_string(),
181///     name: "User".to_string(),
182///     email: "user@example.com".to_string(),
183/// };
184/// let user_token = UserToken::new("jwt123".to_string(), Some(3600), Some(user_info));
185/// save_token("runbeam-cli", "user_auth", &user_token).await?;
186/// # Ok(())
187/// # }
188/// ```
189pub async fn save_token<T>(
190    instance_id: &str,
191    token_type: &str,
192    token: &T,
193) -> Result<(), StorageError>
194where
195    T: Serialize,
196{
197    let token_path = format!("runbeam/{}.json", token_type);
198    tracing::debug!(
199        "Saving token: type={}, instance={}, path={}",
200        token_type,
201        instance_id,
202        token_path
203    );
204
205    let backend = get_storage_backend(instance_id, &token_path).await?;
206
207    // Serialize token to JSON
208    let json = serde_json::to_vec_pretty(&token).map_err(|e| {
209        tracing::error!("Failed to serialize token: {}", e);
210        StorageError::Config(format!("JSON serialization failed: {}", e))
211    })?;
212
213    // Write to storage
214    match backend {
215        StorageBackendType::Keyring(storage) => storage.write_file_str(&token_path, &json).await?,
216        StorageBackendType::Encrypted(storage) => {
217            storage.write_file_str(&token_path, &json).await?
218        }
219    }
220
221    tracing::info!(
222        "Token saved successfully: type={}, instance={}",
223        token_type,
224        instance_id
225    );
226
227    Ok(())
228}
229
230/// Load any token using automatic secure storage selection (generic)
231///
232/// This function checks multiple storage backends to find the token:
233/// 1. **Keyring** (OS keychain) - checked first if available
234/// 2. **Encrypted filesystem** - checked as fallback
235///
236/// This ensures tokens can be loaded regardless of which backend was used during save,
237/// providing resilience against keyring availability changes between operations.
238///
239/// # Type Parameters
240///
241/// * `T` - Any type that implements `DeserializeOwned`
242///
243/// # Arguments
244///
245/// * `instance_id` - Unique identifier for this application instance
246/// * `token_type` - Token type identifier (e.g., "auth", "user_auth", "custom")
247///
248/// # Returns
249///
250/// Returns `Ok(Some(token))` if a token was found and loaded successfully,
251/// `Ok(None)` if no token file exists in any backend, or `Err(StorageError)` if loading failed.
252///
253/// # Examples
254///
255/// ```no_run
256/// use runbeam_sdk::{load_token, MachineToken, UserToken};
257///
258/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
259/// // Load machine token
260/// let machine_token: Option<MachineToken> = load_token("harmony", "auth").await?;
261///
262/// // Load user token
263/// let user_token: Option<UserToken> = load_token("runbeam-cli", "user_auth").await?;
264/// # Ok(())
265/// # }
266/// ```
267pub async fn load_token<T>(instance_id: &str, token_type: &str) -> Result<Option<T>, StorageError>
268where
269    T: DeserializeOwned,
270{
271    let token_path = format!("runbeam/{}.json", token_type);
272    tracing::debug!(
273        "Loading token: type={}, instance={}, path={}",
274        token_type,
275        instance_id,
276        token_path
277    );
278
279    // Check if keyring is disabled
280    let keyring_disabled = std::env::var("RUNBEAM_DISABLE_KEYRING").is_ok();
281
282    // Try keyring first (unless disabled)
283    if !keyring_disabled {
284        tracing::debug!("Attempting to load token from keyring storage");
285        let keyring = KeyringStorage::new("runbeam");
286
287        if keyring.exists_str(&token_path) {
288            tracing::debug!("Token found in keyring, loading...");
289            match keyring.read_file_str(&token_path).await {
290                Ok(json) => {
291                    // Deserialize token
292                    let token: T = serde_json::from_slice(&json).map_err(|e| {
293                        tracing::error!("Failed to deserialize token from keyring: {}", e);
294                        StorageError::Config(format!("JSON deserialization failed: {}", e))
295                    })?;
296
297                    tracing::debug!(
298                        "Token loaded successfully from keyring: type={}",
299                        token_type
300                    );
301                    return Ok(Some(token));
302                }
303                Err(e) => {
304                    tracing::warn!("Token exists in keyring but failed to read: {}", e);
305                    // Fall through to try encrypted filesystem
306                }
307            }
308        } else {
309            tracing::debug!("Token not found in keyring, trying encrypted filesystem");
310        }
311    } else {
312        tracing::debug!("Keyring disabled, skipping keyring check");
313    }
314
315    // Try encrypted filesystem as fallback
316    tracing::debug!("Attempting to load token from encrypted filesystem storage");
317    let encrypted = EncryptedFilesystemStorage::new_with_instance(instance_id)
318        .await
319        .map_err(|e| {
320            tracing::debug!("Failed to initialize encrypted storage: {}", e);
321            e
322        })?;
323
324    if encrypted.exists_str(&token_path) {
325        tracing::debug!("Token found in encrypted filesystem, loading...");
326        let json = encrypted.read_file_str(&token_path).await?;
327
328        // Deserialize token
329        let token: T = serde_json::from_slice(&json).map_err(|e| {
330            tracing::error!(
331                "Failed to deserialize token from encrypted filesystem: {}",
332                e
333            );
334            StorageError::Config(format!("JSON deserialization failed: {}", e))
335        })?;
336
337        tracing::debug!(
338            "Token loaded successfully from encrypted filesystem: type={}",
339            token_type
340        );
341        return Ok(Some(token));
342    }
343
344    tracing::debug!(
345        "No token file found in any storage backend: type={}",
346        token_type
347    );
348    Ok(None)
349}
350
351/// Clear any token from all available storage backends
352///
353/// This function clears tokens from both storage backends to ensure complete removal:
354/// 1. **Keyring** (OS keychain) - if available
355/// 2. **Encrypted filesystem** - always checked
356///
357/// This ensures tokens are fully removed regardless of which backend they were stored in.
358///
359/// # Arguments
360///
361/// * `instance_id` - Unique identifier for this application instance
362/// * `token_type` - Token type identifier (e.g., "auth", "user_auth", "custom")
363///
364/// # Returns
365///
366/// Returns `Ok(())` if the token was cleared successfully or didn't exist,
367/// or `Err(StorageError)` if the operation failed.
368///
369/// # Examples
370///
371/// ```no_run
372/// use runbeam_sdk::clear_token;
373///
374/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
375/// // Clear machine token
376/// clear_token("harmony", "auth").await?;
377///
378/// // Clear user token
379/// clear_token("runbeam-cli", "user_auth").await?;
380/// # Ok(())
381/// # }
382/// ```
383pub async fn clear_token(instance_id: &str, token_type: &str) -> Result<(), StorageError> {
384    let token_path = format!("runbeam/{}.json", token_type);
385    tracing::debug!(
386        "Clearing token: type={}, instance={}, path={}",
387        token_type,
388        instance_id,
389        token_path
390    );
391
392    let mut cleared_any = false;
393
394    // Check if keyring is disabled
395    let keyring_disabled = std::env::var("RUNBEAM_DISABLE_KEYRING").is_ok();
396
397    // Try to clear from keyring first (unless disabled)
398    if !keyring_disabled {
399        let keyring = KeyringStorage::new("runbeam");
400        if keyring.exists_str(&token_path) {
401            tracing::debug!("Clearing token from keyring storage");
402            match keyring.remove_str(&token_path).await {
403                Ok(_) => {
404                    tracing::debug!("Token cleared from keyring");
405                    cleared_any = true;
406                }
407                Err(e) => {
408                    tracing::warn!("Failed to clear token from keyring: {}", e);
409                    // Continue to try encrypted filesystem
410                }
411            }
412        }
413    }
414
415    // Try to clear from encrypted filesystem
416    if let Ok(encrypted) = EncryptedFilesystemStorage::new_with_instance(instance_id).await {
417        if encrypted.exists_str(&token_path) {
418            tracing::debug!("Clearing token from encrypted filesystem storage");
419            match encrypted.remove_str(&token_path).await {
420                Ok(_) => {
421                    tracing::debug!("Token cleared from encrypted filesystem");
422                    cleared_any = true;
423                }
424                Err(e) => {
425                    tracing::warn!("Failed to clear token from encrypted filesystem: {}", e);
426                }
427            }
428        }
429    }
430
431    if cleared_any {
432        tracing::info!("Token cleared successfully: type={}", token_type);
433    } else {
434        tracing::debug!(
435            "No token file to clear in any storage backend: type={}",
436            token_type
437        );
438    }
439
440    Ok(())
441}
442
443// ============================================================================
444// Backwards-Compatible Machine Token Functions
445// ============================================================================
446
447/// Save a machine token with an explicit encryption key
448///
449/// This function uses the provided encryption key for token storage instead of
450/// relying on environment variables or auto-generation. It still prefers OS keyring
451/// when available, falling back to encrypted filesystem with the provided key.
452///
453/// # Arguments
454///
455/// * `instance_id` - Unique identifier for this application instance (e.g., "harmony", "runbeam-cli", "test-123")
456/// * `token` - The machine token to save
457/// * `encryption_key` - Base64-encoded age X25519 encryption key for filesystem storage
458///
459/// # Returns
460///
461/// Returns `Ok(())` if the token was saved successfully, or `Err(StorageError)`
462/// if the operation failed.
463pub async fn save_token_with_key(
464    instance_id: &str,
465    token: &MachineToken,
466    encryption_key: &str,
467) -> Result<(), StorageError> {
468    let token_path = "runbeam/auth.json";
469    tracing::debug!(
470        "Saving machine token with explicit encryption key: gateway={}, instance={}",
471        token.gateway_code,
472        instance_id
473    );
474
475    // Check if keyring is disabled
476    let keyring_disabled = std::env::var("RUNBEAM_DISABLE_KEYRING").is_ok();
477
478    // Try keyring first (unless disabled)
479    let backend = if keyring_disabled {
480        tracing::debug!("Keyring disabled via RUNBEAM_DISABLE_KEYRING environment variable");
481        None
482    } else {
483        let keyring = KeyringStorage::new("runbeam");
484        let path = token_path.to_string();
485        let test_result = tokio::task::spawn_blocking(move || keyring.exists_str(&path)).await;
486
487        match test_result {
488            Ok(_) => {
489                tracing::debug!("Using OS keyring for secure token storage");
490                Some(StorageBackendType::Keyring(KeyringStorage::new("runbeam")))
491            }
492            Err(e) => {
493                tracing::debug!(
494                    "Keyring unavailable ({}), using encrypted filesystem with provided key",
495                    e
496                );
497                None
498            }
499        }
500    };
501
502    let backend = match backend {
503        Some(b) => b,
504        None => {
505            // Use encrypted filesystem with the provided key
506            let encrypted =
507                EncryptedFilesystemStorage::new_with_instance_and_key(instance_id, encryption_key)
508                    .await?;
509            StorageBackendType::Encrypted(encrypted)
510        }
511    };
512
513    // Serialize token to JSON
514    let json = serde_json::to_vec_pretty(&token).map_err(|e| {
515        tracing::error!("Failed to serialize machine token: {}", e);
516        StorageError::Config(format!("JSON serialization failed: {}", e))
517    })?;
518
519    // Write to storage
520    match backend {
521        StorageBackendType::Keyring(storage) => storage.write_file_str(token_path, &json).await?,
522        StorageBackendType::Encrypted(storage) => storage.write_file_str(token_path, &json).await?,
523    }
524
525    tracing::info!(
526        "Machine token saved successfully with explicit key: gateway_id={}, expires_at={}",
527        token.gateway_id,
528        token.expires_at
529    );
530
531    Ok(())
532}
533
534/// Save a machine token using automatic secure storage selection
535///
536/// **Backwards-compatible wrapper** for `save_token(instance_id, "auth", token)`.
537///
538/// This function automatically selects the most secure available storage:
539/// 1. **Keyring** (OS keychain) if available
540/// 2. **Encrypted filesystem** if keyring is unavailable
541///
542/// Tokens are stored at `runbeam/auth.json`.
543///
544/// # Encryption
545///
546/// When using encrypted filesystem storage, the encryption key is sourced from:
547/// 1. `RUNBEAM_ENCRYPTION_KEY` environment variable (base64-encoded)
548/// 2. Auto-generated key stored at `~/.runbeam/<instance_id>/encryption.key` (0600 permissions)
549///
550/// # Arguments
551///
552/// * `instance_id` - Unique identifier for this application instance (e.g., "harmony", "runbeam-cli", "test-123")
553/// * `token` - The machine token to save
554///
555/// # Returns
556///
557/// Returns `Ok(())` if the token was saved successfully, or `Err(StorageError)`
558/// if the operation failed.
559pub async fn save_machine_token(
560    instance_id: &str,
561    token: &MachineToken,
562) -> Result<(), StorageError> {
563    save_token(instance_id, "auth", token).await
564}
565
566/// Load a machine token using automatic secure storage selection
567///
568/// **Backwards-compatible wrapper** for `load_token(instance_id, "auth")`.
569///
570/// This function automatically selects the most secure available storage:
571/// 1. **Keyring** (OS keychain) if available
572/// 2. **Encrypted filesystem** if keyring is unavailable
573///
574/// Attempts to load the token from `runbeam/auth.json`. Returns `None` if the
575/// file doesn't exist.
576///
577/// # Arguments
578///
579/// * `instance_id` - Unique identifier for this application instance (e.g., "harmony", "runbeam-cli")
580///
581/// # Returns
582///
583/// Returns `Ok(Some(token))` if a token was found and loaded successfully,
584/// `Ok(None)` if no token file exists, or `Err(StorageError)` if loading failed.
585pub async fn load_machine_token(instance_id: &str) -> Result<Option<MachineToken>, StorageError> {
586    load_token(instance_id, "auth").await
587}
588
589/// Clear the machine token using automatic secure storage selection
590///
591/// **Backwards-compatible wrapper** for `clear_token(instance_id, "auth")`.
592///
593/// This function automatically selects the most secure available storage:
594/// 1. **Keyring** (OS keychain) if available
595/// 2. **Encrypted filesystem** if keyring is unavailable
596///
597/// Removes the token file at `runbeam/auth.json` if it exists.
598///
599/// # Arguments
600///
601/// * `instance_id` - Unique identifier for this application instance (e.g., "harmony", "runbeam-cli")
602///
603/// # Returns
604///
605/// Returns `Ok(())` if the token was cleared successfully or didn't exist,
606/// or `Err(StorageError)` if the operation failed.
607pub async fn clear_machine_token(instance_id: &str) -> Result<(), StorageError> {
608    clear_token(instance_id, "auth").await
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use serial_test::serial;
615
616    /// Setup test encryption key and return cleanup function
617    fn setup_test_encryption() -> impl Drop {
618        use base64::Engine;
619        use secrecy::ExposeSecret;
620        use std::env;
621
622        let identity = age::x25519::Identity::generate();
623        let key_base64 = base64::engine::general_purpose::STANDARD
624            .encode(identity.to_string().expose_secret().as_bytes());
625        env::set_var("RUNBEAM_ENCRYPTION_KEY", &key_base64);
626
627        // Disable keyring in tests to force encrypted filesystem storage
628        // This ensures consistent test behavior across platforms
629        env::set_var("RUNBEAM_DISABLE_KEYRING", "1");
630
631        // Return a guard that will clean up on drop
632        struct Guard;
633        impl Drop for Guard {
634            fn drop(&mut self) {
635                std::env::remove_var("RUNBEAM_ENCRYPTION_KEY");
636                std::env::remove_var("RUNBEAM_DISABLE_KEYRING");
637            }
638        }
639        Guard
640    }
641
642    #[test]
643    fn test_machine_token_creation() {
644        let token = MachineToken::new(
645            "test_token".to_string(),
646            "2025-12-31T23:59:59Z".to_string(),
647            "gw123".to_string(),
648            "gateway-code-123".to_string(),
649            vec!["harmony:send".to_string(), "harmony:receive".to_string()],
650        );
651
652        assert_eq!(token.machine_token, "test_token");
653        assert_eq!(token.gateway_id, "gw123");
654        assert_eq!(token.gateway_code, "gateway-code-123");
655        assert_eq!(token.abilities.len(), 2);
656        assert!(!token.issued_at.is_empty());
657    }
658
659    #[test]
660    fn test_machine_token_is_expired() {
661        // Expired token (year 2020)
662        let expired_token = MachineToken::new(
663            "test_token".to_string(),
664            "2020-01-01T00:00:00Z".to_string(),
665            "gw123".to_string(),
666            "gateway-code-123".to_string(),
667            vec![],
668        );
669        assert!(expired_token.is_expired());
670        assert!(!expired_token.is_valid());
671
672        // Valid token (far future)
673        let valid_token = MachineToken::new(
674            "test_token".to_string(),
675            "2099-12-31T23:59:59Z".to_string(),
676            "gw123".to_string(),
677            "gateway-code-123".to_string(),
678            vec![],
679        );
680        assert!(!valid_token.is_expired());
681        assert!(valid_token.is_valid());
682    }
683
684    #[test]
685    fn test_machine_token_serialization() {
686        let token = MachineToken::new(
687            "test_token".to_string(),
688            "2025-12-31T23:59:59Z".to_string(),
689            "gw123".to_string(),
690            "gateway-code-123".to_string(),
691            vec!["harmony:send".to_string()],
692        );
693
694        let json = serde_json::to_string(&token).unwrap();
695        assert!(json.contains("\"machine_token\":\"test_token\""));
696        assert!(json.contains("\"gateway_id\":\"gw123\""));
697        assert!(json.contains("\"gateway_code\":\"gateway-code-123\""));
698
699        // Deserialize and verify
700        let deserialized: MachineToken = serde_json::from_str(&json).unwrap();
701        assert_eq!(deserialized.machine_token, token.machine_token);
702        assert_eq!(deserialized.gateway_id, token.gateway_id);
703    }
704
705    #[tokio::test]
706    #[serial]
707    async fn test_save_and_load_token_secure() {
708        let _guard = setup_test_encryption();
709        let instance_id = "test-save-load";
710        // Clear any existing token first
711        let _ = clear_machine_token(instance_id).await;
712
713        let token = MachineToken::new(
714            "test_token_secure".to_string(),
715            "2099-12-31T23:59:59Z".to_string(),
716            "gw_test".to_string(),
717            "test-gateway".to_string(),
718            vec!["harmony:send".to_string()],
719        );
720
721        // Save token using automatic secure storage (wrapper)
722        save_machine_token(instance_id, &token).await.unwrap();
723
724        // Load token using automatic secure storage (wrapper)
725        let loaded = load_machine_token(instance_id).await.unwrap();
726        assert!(loaded.is_some());
727
728        let loaded_token = loaded.unwrap();
729        assert_eq!(loaded_token.machine_token, token.machine_token);
730        assert_eq!(loaded_token.gateway_id, token.gateway_id);
731        assert_eq!(loaded_token.gateway_code, token.gateway_code);
732        assert!(loaded_token.is_valid());
733
734        // Cleanup
735        clear_machine_token(instance_id).await.unwrap();
736    }
737
738    #[tokio::test]
739    #[serial]
740    async fn test_load_nonexistent_token_secure() {
741        let _guard = setup_test_encryption();
742        let instance_id = "test-nonexistent";
743        // Clear any existing token
744        let _ = clear_machine_token(instance_id).await;
745
746        // Load from empty storage should return None
747        let result = load_machine_token(instance_id).await.unwrap();
748        assert!(result.is_none());
749    }
750
751    #[tokio::test]
752    #[serial]
753    async fn test_clear_token_secure() {
754        let _guard = setup_test_encryption();
755        let instance_id = "test-clear";
756        // Clear any existing token first
757        let _ = clear_machine_token(instance_id).await;
758
759        let token = MachineToken::new(
760            "test_clear".to_string(),
761            "2099-12-31T23:59:59Z".to_string(),
762            "gw_clear".to_string(),
763            "clear-test".to_string(),
764            vec![],
765        );
766
767        // Save token
768        save_machine_token(instance_id, &token).await.unwrap();
769
770        // Verify it exists
771        assert!(load_machine_token(instance_id).await.unwrap().is_some());
772
773        // Clear token
774        clear_machine_token(instance_id).await.unwrap();
775
776        // Verify it's gone
777        assert!(load_machine_token(instance_id).await.unwrap().is_none());
778    }
779
780    #[tokio::test]
781    #[serial]
782    async fn test_clear_nonexistent_token_secure() {
783        let _guard = setup_test_encryption();
784        let instance_id = "test-clear-nonexistent";
785        // Clear token that doesn't exist should not error
786        clear_machine_token(instance_id).await.unwrap();
787    }
788
789    #[tokio::test]
790    #[serial]
791    async fn test_token_expiry_detection() {
792        let _guard = setup_test_encryption();
793        let instance_id = "test-expiry";
794        let _ = clear_machine_token(instance_id).await;
795
796        // Create expired token
797        let expired_token = MachineToken::new(
798            "expired_token".to_string(),
799            "2020-01-01T00:00:00Z".to_string(),
800            "gw_expired".to_string(),
801            "expired-gateway".to_string(),
802            vec![],
803        );
804
805        save_machine_token(instance_id, &expired_token)
806            .await
807            .unwrap();
808
809        // Load and verify it's marked as expired
810        let loaded = load_machine_token(instance_id).await.unwrap();
811        assert!(loaded.is_some());
812        let loaded_token = loaded.unwrap();
813        assert!(loaded_token.is_expired());
814        assert!(!loaded_token.is_valid());
815
816        // Cleanup
817        clear_machine_token(instance_id).await.unwrap();
818    }
819
820    #[tokio::test]
821    #[serial]
822    async fn test_token_with_abilities() {
823        let _guard = setup_test_encryption();
824        let instance_id = "test-abilities";
825        let _ = clear_machine_token(instance_id).await;
826
827        let token = MachineToken::new(
828            "token_with_abilities".to_string(),
829            "2099-12-31T23:59:59Z".to_string(),
830            "gw_abilities".to_string(),
831            "abilities-test".to_string(),
832            vec![
833                "harmony:send".to_string(),
834                "harmony:receive".to_string(),
835                "harmony:config".to_string(),
836            ],
837        );
838
839        save_machine_token(instance_id, &token).await.unwrap();
840
841        let loaded = load_machine_token(instance_id).await.unwrap().unwrap();
842        assert_eq!(loaded.abilities.len(), 3);
843        assert!(loaded.abilities.contains(&"harmony:send".to_string()));
844        assert!(loaded.abilities.contains(&"harmony:receive".to_string()));
845        assert!(loaded.abilities.contains(&"harmony:config".to_string()));
846
847        // Cleanup
848        clear_machine_token(instance_id).await.unwrap();
849    }
850
851    #[tokio::test]
852    #[serial]
853    async fn test_token_overwrites_existing() {
854        let _guard = setup_test_encryption();
855        let instance_id = "test-overwrite";
856        let _ = clear_machine_token(instance_id).await;
857
858        // Save first token
859        let token1 = MachineToken::new(
860            "first_token".to_string(),
861            "2099-12-31T23:59:59Z".to_string(),
862            "gw_first".to_string(),
863            "first-gateway".to_string(),
864            vec![],
865        );
866        save_machine_token(instance_id, &token1).await.unwrap();
867
868        // Save second token (should overwrite)
869        let token2 = MachineToken::new(
870            "second_token".to_string(),
871            "2099-12-31T23:59:59Z".to_string(),
872            "gw_second".to_string(),
873            "second-gateway".to_string(),
874            vec![],
875        );
876        save_machine_token(instance_id, &token2).await.unwrap();
877
878        // Should load second token
879        let loaded = load_machine_token(instance_id).await.unwrap().unwrap();
880        assert_eq!(loaded.machine_token, "second_token");
881        assert_eq!(loaded.gateway_id, "gw_second");
882        assert_eq!(loaded.gateway_code, "second-gateway");
883
884        // Cleanup
885        clear_machine_token(instance_id).await.unwrap();
886    }
887
888    #[tokio::test]
889    async fn test_token_encrypted_on_disk() {
890        use crate::storage::EncryptedFilesystemStorage;
891        use tempfile::TempDir;
892
893        let instance_id = "test-encryption-verify";
894        let temp_dir = TempDir::new().unwrap();
895
896        // Create token with sensitive data
897        let token = MachineToken::new(
898            "super_secret_token_12345".to_string(),
899            "2099-12-31T23:59:59Z".to_string(),
900            "gw_secret".to_string(),
901            "secret-gateway".to_string(),
902            vec!["harmony:admin".to_string()],
903        );
904
905        // Use encrypted storage directly for testing
906        let storage_path = temp_dir.path().join(instance_id);
907        let storage = EncryptedFilesystemStorage::new(&storage_path)
908            .await
909            .unwrap();
910
911        // Save token
912        let token_json = serde_json::to_vec(&token).unwrap();
913        storage
914            .write_file_str("auth.json", &token_json)
915            .await
916            .unwrap();
917
918        // Find the stored token file
919        let token_path = storage_path.join("auth.json");
920
921        // Verify file exists
922        assert!(
923            token_path.exists(),
924            "Token file should exist at {:?}",
925            token_path
926        );
927
928        // Read raw file contents
929        let raw_contents = std::fs::read(&token_path).unwrap();
930        let raw_string = String::from_utf8_lossy(&raw_contents);
931
932        // Verify the file does NOT contain plaintext sensitive data
933        assert!(
934            !raw_string.contains("super_secret_token_12345"),
935            "Token file should NOT contain plaintext token: {}",
936            raw_string
937        );
938        assert!(
939            !raw_string.contains("gw_secret"),
940            "Token file should NOT contain plaintext gateway_id: {}",
941            raw_string
942        );
943        assert!(
944            !raw_string.contains("secret-gateway"),
945            "Token file should NOT contain plaintext gateway_code: {}",
946            raw_string
947        );
948        assert!(
949            !raw_string.contains("harmony:admin"),
950            "Token file should NOT contain plaintext abilities: {}",
951            raw_string
952        );
953
954        // Verify it contains age encryption markers
955        if raw_contents.len() > 50 {
956            // age encryption typically starts with "age-encryption.org/v1"
957            let has_age_header = raw_string.starts_with("age-encryption.org/v1");
958            assert!(
959                has_age_header || raw_contents.starts_with(b"age-encryption.org/v1"),
960                "File should contain age encryption header. Raw contents (first 100 bytes): {:?}",
961                &raw_contents[..std::cmp::min(100, raw_contents.len())]
962            );
963        }
964
965        // Verify we can still decrypt and load the token correctly
966        let decrypted_data = storage.read_file_str("auth.json").await.unwrap();
967        let loaded_token: MachineToken = serde_json::from_slice(&decrypted_data).unwrap();
968        assert_eq!(loaded_token.machine_token, "super_secret_token_12345");
969        assert_eq!(loaded_token.gateway_id, "gw_secret");
970    }
971
972    #[tokio::test]
973    async fn test_token_file_cannot_be_read_as_json() {
974        use crate::storage::EncryptedFilesystemStorage;
975        use tempfile::TempDir;
976
977        let instance_id = "test-raw-json-read";
978        let temp_dir = TempDir::new().unwrap();
979        let storage_path = temp_dir.path().join(instance_id);
980
981        let token = MachineToken::new(
982            "test_token_json".to_string(),
983            "2099-12-31T23:59:59Z".to_string(),
984            "gw_json".to_string(),
985            "json-test".to_string(),
986            vec![],
987        );
988
989        // Use encrypted storage directly
990        let storage = EncryptedFilesystemStorage::new(&storage_path)
991            .await
992            .unwrap();
993        let token_json = serde_json::to_vec(&token).unwrap();
994        storage
995            .write_file_str("auth.json", &token_json)
996            .await
997            .unwrap();
998
999        // Try to read the file as JSON
1000        let token_path = storage_path.join("auth.json");
1001
1002        // Read as bytes first (encrypted data may not be valid UTF-8)
1003        let raw_contents = std::fs::read(&token_path).unwrap();
1004
1005        // Try to parse as JSON - should fail because it's encrypted
1006        let json_parse_result: Result<serde_json::Value, _> = serde_json::from_slice(&raw_contents);
1007        assert!(
1008            json_parse_result.is_err(),
1009            "Raw token file should NOT be parseable as JSON (it should be encrypted)"
1010        );
1011    }
1012
1013    #[tokio::test]
1014    async fn test_token_different_from_plaintext() {
1015        use crate::storage::EncryptedFilesystemStorage;
1016        use tempfile::TempDir;
1017
1018        let instance_id = "test-plaintext-compare";
1019        let temp_dir = TempDir::new().unwrap();
1020        let storage_path = temp_dir.path().join(instance_id);
1021
1022        let token = MachineToken::new(
1023            "comparison_token".to_string(),
1024            "2099-12-31T23:59:59Z".to_string(),
1025            "gw_compare".to_string(),
1026            "compare-gateway".to_string(),
1027            vec!["test:ability".to_string()],
1028        );
1029
1030        // Get plaintext JSON representation
1031        let plaintext_json = serde_json::to_vec(&token).unwrap();
1032
1033        // Use encrypted storage directly
1034        let storage = EncryptedFilesystemStorage::new(&storage_path)
1035            .await
1036            .unwrap();
1037        storage
1038            .write_file_str("auth.json", &plaintext_json)
1039            .await
1040            .unwrap();
1041
1042        // Read encrypted file
1043        let token_path = storage_path.join("auth.json");
1044        let encrypted_contents = std::fs::read(&token_path).unwrap();
1045
1046        // Encrypted contents should be different from plaintext
1047        assert_ne!(
1048            encrypted_contents, plaintext_json,
1049            "Encrypted file contents should differ from plaintext JSON"
1050        );
1051
1052        // Encrypted contents should be longer (encryption overhead)
1053        assert!(
1054            encrypted_contents.len() > plaintext_json.len(),
1055            "Encrypted file should be larger due to encryption overhead. Encrypted: {}, Plaintext: {}",
1056            encrypted_contents.len(),
1057            plaintext_json.len()
1058        );
1059    }
1060
1061    #[tokio::test]
1062    async fn test_multiple_instances_isolated() {
1063        use crate::storage::EncryptedFilesystemStorage;
1064        use tempfile::TempDir;
1065
1066        let temp_dir = TempDir::new().unwrap();
1067
1068        // Create tokens for different instances
1069        let token1 = MachineToken::new(
1070            "token_instance_1".to_string(),
1071            "2099-12-31T23:59:59Z".to_string(),
1072            "gw_1".to_string(),
1073            "gateway-1".to_string(),
1074            vec![],
1075        );
1076
1077        let token2 = MachineToken::new(
1078            "token_instance_2".to_string(),
1079            "2099-12-31T23:59:59Z".to_string(),
1080            "gw_2".to_string(),
1081            "gateway-2".to_string(),
1082            vec![],
1083        );
1084
1085        // Create separate storage instances
1086        let storage1_path = temp_dir.path().join("instance-1");
1087        let storage2_path = temp_dir.path().join("instance-2");
1088
1089        let storage1 = EncryptedFilesystemStorage::new(&storage1_path)
1090            .await
1091            .unwrap();
1092        let storage2 = EncryptedFilesystemStorage::new(&storage2_path)
1093            .await
1094            .unwrap();
1095
1096        // Save tokens
1097        let token1_json = serde_json::to_vec(&token1).unwrap();
1098        let token2_json = serde_json::to_vec(&token2).unwrap();
1099        storage1
1100            .write_file_str("auth.json", &token1_json)
1101            .await
1102            .unwrap();
1103        storage2
1104            .write_file_str("auth.json", &token2_json)
1105            .await
1106            .unwrap();
1107
1108        // Verify files are in separate directories
1109        let path1 = storage1_path.join("auth.json");
1110        let path2 = storage2_path.join("auth.json");
1111
1112        assert!(path1.exists(), "Instance 1 token file should exist");
1113        assert!(path2.exists(), "Instance 2 token file should exist");
1114        assert_ne!(path1, path2, "Token files should be in different locations");
1115
1116        // Verify encryption keys are separate (if not using env var)
1117        let key1_path = storage1_path.join("encryption.key");
1118        let key2_path = storage2_path.join("encryption.key");
1119
1120        if key1_path.exists() && key2_path.exists() {
1121            let key1_contents = std::fs::read(&key1_path).unwrap();
1122            let key2_contents = std::fs::read(&key2_path).unwrap();
1123            assert_ne!(
1124                key1_contents, key2_contents,
1125                "Encryption keys should be different for each instance"
1126            );
1127        }
1128
1129        // Verify tokens are isolated by decrypting them
1130        let decrypted1 = storage1.read_file_str("auth.json").await.unwrap();
1131        let decrypted2 = storage2.read_file_str("auth.json").await.unwrap();
1132
1133        let loaded1: MachineToken = serde_json::from_slice(&decrypted1).unwrap();
1134        let loaded2: MachineToken = serde_json::from_slice(&decrypted2).unwrap();
1135
1136        assert_eq!(loaded1.machine_token, "token_instance_1");
1137        assert_eq!(loaded1.gateway_code, "gateway-1");
1138        assert_eq!(loaded2.machine_token, "token_instance_2");
1139        assert_eq!(loaded2.gateway_code, "gateway-2");
1140    }
1141
1142    #[tokio::test]
1143    #[cfg(unix)]
1144    async fn test_encryption_key_file_permissions() {
1145        use crate::storage::EncryptedFilesystemStorage;
1146        use std::os::unix::fs::PermissionsExt;
1147        use tempfile::TempDir;
1148
1149        let instance_id = "test-key-permissions";
1150        let temp_dir = TempDir::new().unwrap();
1151        let storage_path = temp_dir.path().join(instance_id);
1152
1153        // Create storage which will generate encryption key
1154        let _storage = EncryptedFilesystemStorage::new(&storage_path)
1155            .await
1156            .unwrap();
1157
1158        // Check encryption key file permissions
1159        let key_path = storage_path.join("encryption.key");
1160
1161        // Key may be in storage path or env var - check if file was created
1162        if !key_path.exists() {
1163            // If using RUNBEAM_ENCRYPTION_KEY env var, skip this test
1164            return;
1165        }
1166
1167        let metadata = std::fs::metadata(&key_path).unwrap();
1168        let permissions = metadata.permissions();
1169        let mode = permissions.mode();
1170
1171        // Check that only owner has read/write (0600)
1172        let permission_bits = mode & 0o777;
1173        assert_eq!(
1174            permission_bits, 0o600,
1175            "Encryption key file should have 0600 permissions (owner read/write only), got {:o}",
1176            permission_bits
1177        );
1178    }
1179
1180    #[tokio::test]
1181    async fn test_tampered_token_file_fails_to_load() {
1182        use crate::storage::EncryptedFilesystemStorage;
1183        use tempfile::TempDir;
1184
1185        let instance_id = "test-tamper";
1186        let temp_dir = TempDir::new().unwrap();
1187        let storage_path = temp_dir.path().join(instance_id);
1188
1189        let token = MachineToken::new(
1190            "original_token".to_string(),
1191            "2099-12-31T23:59:59Z".to_string(),
1192            "gw_tamper".to_string(),
1193            "tamper-test".to_string(),
1194            vec![],
1195        );
1196
1197        // Use encrypted storage directly
1198        let storage = EncryptedFilesystemStorage::new(&storage_path)
1199            .await
1200            .unwrap();
1201        let token_json = serde_json::to_vec(&token).unwrap();
1202        storage
1203            .write_file_str("auth.json", &token_json)
1204            .await
1205            .unwrap();
1206
1207        // Tamper with the encrypted file
1208        let token_path = storage_path.join("auth.json");
1209        let mut contents = std::fs::read(&token_path).unwrap();
1210
1211        // Flip some bytes in the middle of the file
1212        if contents.len() > 50 {
1213            contents[25] = contents[25].wrapping_add(1);
1214            contents[30] = contents[30].wrapping_sub(1);
1215            std::fs::write(&token_path, contents).unwrap();
1216        }
1217
1218        // Attempting to decrypt should fail
1219        let result = storage.read_file_str("auth.json").await;
1220        assert!(
1221            result.is_err(),
1222            "Loading tampered encrypted file should fail"
1223        );
1224    }
1225
1226    // ========================================================================
1227    // Generic Token Storage Tests
1228    // ========================================================================
1229
1230    #[tokio::test]
1231    #[serial]
1232    async fn test_generic_save_and_load_user_token() {
1233        use crate::runbeam_api::types::UserToken;
1234        let _guard = setup_test_encryption();
1235        let instance_id = "test-user-token";
1236        clear_token(instance_id, "user_auth").await.ok();
1237
1238        let user_token = UserToken::new(
1239            "user_jwt_token".to_string(),
1240            Some(1234567890),
1241            Some(crate::runbeam_api::types::UserInfo {
1242                id: "user123".to_string(),
1243                name: "Test User".to_string(),
1244                email: "test@example.com".to_string(),
1245            }),
1246        );
1247
1248        // Save using generic function
1249        save_token(instance_id, "user_auth", &user_token)
1250            .await
1251            .unwrap();
1252
1253        // Load using generic function
1254        let loaded: Option<UserToken> = load_token(instance_id, "user_auth").await.unwrap();
1255        assert!(loaded.is_some());
1256
1257        let loaded_token = loaded.unwrap();
1258        assert_eq!(loaded_token.token, user_token.token);
1259        assert_eq!(loaded_token.expires_at, user_token.expires_at);
1260        assert!(loaded_token.user.is_some());
1261
1262        // Cleanup
1263        clear_token(instance_id, "user_auth").await.unwrap();
1264    }
1265
1266    #[tokio::test]
1267    #[serial]
1268    async fn test_different_token_types_isolated() {
1269        use crate::runbeam_api::types::UserToken;
1270        let _guard = setup_test_encryption();
1271        let instance_id = "test-isolation";
1272
1273        // Create different token types
1274        let user_token = UserToken::new("user_token".to_string(), None, None);
1275
1276        let machine_token = MachineToken::new(
1277            "machine_token".to_string(),
1278            "2099-12-31T23:59:59Z".to_string(),
1279            "gw_test".to_string(),
1280            "test-gw".to_string(),
1281            vec![],
1282        );
1283
1284        // Save both
1285        save_token(instance_id, "user_auth", &user_token)
1286            .await
1287            .unwrap();
1288        save_token(instance_id, "auth", &machine_token)
1289            .await
1290            .unwrap();
1291
1292        // Load both - should be independent
1293        let loaded_user: Option<UserToken> = load_token(instance_id, "user_auth").await.unwrap();
1294        let loaded_machine: Option<MachineToken> = load_token(instance_id, "auth").await.unwrap();
1295
1296        assert!(loaded_user.is_some());
1297        assert!(loaded_machine.is_some());
1298        assert_eq!(loaded_user.unwrap().token, "user_token");
1299        assert_eq!(loaded_machine.unwrap().machine_token, "machine_token");
1300
1301        // Cleanup
1302        clear_token(instance_id, "user_auth").await.unwrap();
1303        clear_token(instance_id, "auth").await.unwrap();
1304    }
1305
1306    #[tokio::test]
1307    #[serial]
1308    async fn test_user_token_with_full_metadata() {
1309        use crate::runbeam_api::types::UserToken;
1310        let _guard = setup_test_encryption();
1311        let instance_id = "test-user-full";
1312        clear_token(instance_id, "user_auth").await.ok();
1313
1314        let user_token = UserToken::new(
1315            "detailed_user_token".to_string(),
1316            Some(2000000000),
1317            Some(crate::runbeam_api::types::UserInfo {
1318                id: "user456".to_string(),
1319                name: "John Doe".to_string(),
1320                email: "john@example.com".to_string(),
1321            }),
1322        );
1323
1324        // Save and load
1325        save_token(instance_id, "user_auth", &user_token)
1326            .await
1327            .unwrap();
1328        let loaded: Option<UserToken> = load_token(instance_id, "user_auth").await.unwrap();
1329
1330        assert!(loaded.is_some());
1331        let loaded_token = loaded.unwrap();
1332        assert_eq!(loaded_token.token, "detailed_user_token");
1333        assert_eq!(loaded_token.expires_at, Some(2000000000));
1334
1335        let user_info = loaded_token.user.unwrap();
1336        assert_eq!(user_info.id, "user456");
1337        assert_eq!(user_info.name, "John Doe");
1338        assert_eq!(user_info.email, "john@example.com");
1339
1340        // Cleanup
1341        clear_token(instance_id, "user_auth").await.unwrap();
1342    }
1343}