runbeam_sdk/runbeam_api/
token_storage.rs

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