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