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