Skip to main content

synth_ai_core/
auth.rs

1//! Authentication utilities for Synth SDK.
2//!
3//! This module provides:
4//! - API key resolution from environment and config files
5//! - Credential storage in `~/.synth-ai/user_config.json`
6//! - Demo key minting
7//! - Device authentication flow (OAuth-style browser auth)
8
9use crate::errors::CoreError;
10use crate::shared_client::build_pooled_client;
11use crate::utils;
12use crate::urls::backend_url_base;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::env;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21/// Default config directory name
22pub const CONFIG_DIR: &str = ".synth-ai";
23
24/// Default config file name
25pub const CONFIG_FILE: &str = "user_config.json";
26/// Default container config file name
27pub const CONTAINER_CONFIG_FILE: &str = "container_config.json";
28
29/// Default environment variable for API key
30pub const ENV_API_KEY: &str = "SYNTH_API_KEY";
31
32/// Default environment variable for environment API key
33pub const ENV_ENVIRONMENT_API_KEY: &str = "ENVIRONMENT_API_KEY";
34
35/// Default frontend URL for device auth
36pub const DEFAULT_FRONTEND_URL: &str = "https://usesynth.ai";
37
38/// Default backend URL for API calls
39pub const DEFAULT_BACKEND_URL: &str = crate::urls::DEFAULT_BACKEND_URL;
40
41/// Get the default config directory path (~/.synth-ai).
42pub fn get_config_dir() -> PathBuf {
43    dirs::home_dir()
44        .unwrap_or_else(|| PathBuf::from("."))
45        .join(CONFIG_DIR)
46}
47
48/// Get the default config file path (~/.synth-ai/user_config.json).
49pub fn get_config_path() -> PathBuf {
50    get_config_dir().join(CONFIG_FILE)
51}
52
53/// Get the default container config file path (~/.synth-ai/container_config.json).
54pub fn get_container_config_path() -> PathBuf {
55    get_config_dir().join(CONTAINER_CONFIG_FILE)
56}
57
58/// Load full user config JSON (not just string credentials).
59pub fn load_user_config() -> Result<HashMap<String, Value>, CoreError> {
60    let path = get_config_path();
61    if !path.exists() {
62        return Ok(HashMap::new());
63    }
64    let content = fs::read_to_string(&path)
65        .map_err(|e| CoreError::Config(format!("failed to read config: {}", e)))?;
66    let value: Value = serde_json::from_str(&content)
67        .map_err(|e| CoreError::Config(format!("invalid config JSON: {}", e)))?;
68    let mut result = HashMap::new();
69    if let Value::Object(map) = value {
70        for (k, v) in map {
71            result.insert(k, v);
72        }
73    }
74    Ok(result)
75}
76
77/// Save full user config JSON.
78pub fn save_user_config(config: &HashMap<String, Value>) -> Result<(), CoreError> {
79    let path = get_config_path();
80    let value = Value::Object(config.clone().into_iter().collect());
81    utils::write_private_json(&path, &value)
82        .map_err(|e| CoreError::Config(format!("failed to write config: {}", e)))?;
83    Ok(())
84}
85
86/// Update user config with provided values.
87pub fn update_user_config(
88    updates: &HashMap<String, Value>,
89) -> Result<HashMap<String, Value>, CoreError> {
90    let mut current = load_user_config()?;
91    for (k, v) in updates {
92        current.insert(k.clone(), v.clone());
93    }
94    save_user_config(&current)?;
95    Ok(current)
96}
97
98/// Get API key from environment variable.
99///
100/// # Arguments
101///
102/// * `env_key` - Environment variable name (defaults to SYNTH_API_KEY)
103pub fn get_api_key_from_env(env_key: Option<&str>) -> Option<String> {
104    let key = env_key.unwrap_or(ENV_API_KEY);
105    env::var(key).ok().filter(|s| !s.trim().is_empty())
106}
107
108/// Load credentials from a JSON config file.
109///
110/// # Arguments
111///
112/// * `config_path` - Path to config file (defaults to ~/.synth-ai/user_config.json)
113pub fn load_credentials(config_path: Option<&Path>) -> Result<HashMap<String, String>, CoreError> {
114    let path = config_path
115        .map(|p| p.to_path_buf())
116        .unwrap_or_else(get_config_path);
117
118    if !path.exists() {
119        return Ok(HashMap::new());
120    }
121
122    let content = fs::read_to_string(&path)
123        .map_err(|e| CoreError::Config(format!("failed to read config: {}", e)))?;
124
125    let value: Value = serde_json::from_str(&content)
126        .map_err(|e| CoreError::Config(format!("invalid config JSON: {}", e)))?;
127
128    let mut result = HashMap::new();
129    if let Value::Object(map) = value {
130        for (k, v) in map {
131            if let Value::String(s) = v {
132                result.insert(k, s);
133            }
134        }
135    }
136
137    Ok(result)
138}
139
140/// Store credentials to a JSON config file.
141///
142/// This creates the parent directory if it doesn't exist and sets
143/// restrictive permissions (0600) on the file.
144///
145/// # Arguments
146///
147/// * `credentials` - Map of credential names to values
148/// * `config_path` - Path to config file (defaults to ~/.synth-ai/user_config.json)
149pub fn store_credentials(
150    credentials: &HashMap<String, String>,
151    config_path: Option<&Path>,
152) -> Result<(), CoreError> {
153    let path = config_path
154        .map(|p| p.to_path_buf())
155        .unwrap_or_else(get_config_path);
156
157    // Create parent directory
158    if let Some(parent) = path.parent() {
159        fs::create_dir_all(parent)
160            .map_err(|e| CoreError::Config(format!("failed to create config dir: {}", e)))?;
161    }
162
163    // Load existing config and merge
164    let mut existing = load_credentials(Some(&path)).unwrap_or_default();
165    for (k, v) in credentials {
166        existing.insert(k.clone(), v.clone());
167    }
168
169    // Write config
170    let content = serde_json::to_string_pretty(&existing)
171        .map_err(|e| CoreError::Config(format!("failed to serialize config: {}", e)))?;
172
173    fs::write(&path, content)
174        .map_err(|e| CoreError::Config(format!("failed to write config: {}", e)))?;
175
176    // Set restrictive permissions on Unix
177    #[cfg(unix)]
178    {
179        use std::os::unix::fs::PermissionsExt;
180        let perms = fs::Permissions::from_mode(0o600);
181        let _ = fs::set_permissions(&path, perms);
182    }
183
184    Ok(())
185}
186
187/// Get API key from environment or config file.
188///
189/// Resolution order:
190/// 1. Environment variable (SYNTH_API_KEY by default)
191/// 2. Config file (~/.synth-ai/user_config.json)
192///
193/// # Arguments
194///
195/// * `env_key` - Environment variable name (defaults to SYNTH_API_KEY)
196pub fn get_api_key(env_key: Option<&str>) -> Option<String> {
197    // Check environment first
198    if let Some(key) = get_api_key_from_env(env_key) {
199        return Some(key);
200    }
201
202    // Check config file
203    if let Ok(creds) = load_credentials(None) {
204        let key_name = env_key.unwrap_or(ENV_API_KEY);
205        if let Some(key) = creds.get(key_name) {
206            if !key.trim().is_empty() {
207                return Some(key.clone());
208            }
209        }
210    }
211
212    None
213}
214
215/// Device authentication session.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct DeviceAuthSession {
218    /// Device code for polling
219    pub device_code: String,
220    /// URL for user to visit in browser
221    pub verification_uri: String,
222    /// Session expiration time (Unix timestamp)
223    pub expires_at: f64,
224}
225
226/// Response from device auth token endpoint.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct DeviceAuthResponse {
229    /// Synth API key
230    #[serde(default)]
231    pub synth_api_key: Option<String>,
232    /// Environment API key
233    #[serde(default)]
234    pub environment_api_key: Option<String>,
235    /// Legacy keys format
236    #[serde(default)]
237    pub keys: Option<HashMap<String, String>>,
238}
239
240/// Initialize a device authentication session.
241///
242/// This starts the OAuth-style device auth flow by requesting a device code
243/// from the backend.
244///
245/// # Arguments
246///
247/// * `frontend_url` - Frontend URL (defaults to https://usesynth.ai)
248pub async fn init_device_auth(frontend_url: Option<&str>) -> Result<DeviceAuthSession, CoreError> {
249    let base = frontend_url
250        .unwrap_or(DEFAULT_FRONTEND_URL)
251        .trim_end_matches('/');
252    let url = format!("{}/api/auth/device/init", base);
253
254    let client = build_pooled_client(Some(10));
255    let resp =
256        client.post(&url).send().await.map_err(|e| {
257            CoreError::Authentication(format!("failed to reach init endpoint: {}", e))
258        })?;
259
260    if !resp.status().is_success() {
261        let status = resp.status();
262        let body = resp.text().await.unwrap_or_default();
263        return Err(CoreError::Authentication(format!(
264            "init failed ({}): {}",
265            status,
266            body.trim()
267        )));
268    }
269
270    let data: Value = resp
271        .json()
272        .await
273        .map_err(|e| CoreError::Authentication(format!("invalid JSON response: {}", e)))?;
274
275    let device_code = data["device_code"]
276        .as_str()
277        .filter(|s| !s.is_empty())
278        .ok_or_else(|| CoreError::Authentication("missing device_code".to_string()))?
279        .to_string();
280
281    let verification_uri = data["verification_uri"]
282        .as_str()
283        .filter(|s| !s.is_empty())
284        .ok_or_else(|| CoreError::Authentication("missing verification_uri".to_string()))?
285        .to_string();
286
287    let expires_in = data["expires_in"].as_i64().unwrap_or(600);
288    let expires_at = now_timestamp() + expires_in as f64;
289
290    Ok(DeviceAuthSession {
291        device_code,
292        verification_uri,
293        expires_at,
294    })
295}
296
297/// Poll for device auth token.
298///
299/// This polls the token endpoint until credentials are returned or timeout.
300///
301/// # Arguments
302///
303/// * `frontend_url` - Frontend URL (defaults to https://usesynth.ai)
304/// * `device_code` - Device code from init
305/// * `poll_interval_secs` - Polling interval (default 3 seconds)
306/// * `timeout_secs` - Overall timeout (default 600 seconds)
307pub async fn poll_device_token(
308    frontend_url: Option<&str>,
309    device_code: &str,
310    poll_interval_secs: Option<u64>,
311    timeout_secs: Option<u64>,
312) -> Result<HashMap<String, String>, CoreError> {
313    let base = frontend_url
314        .unwrap_or(DEFAULT_FRONTEND_URL)
315        .trim_end_matches('/');
316    let url = format!("{}/api/auth/device/token", base);
317    let poll_interval = Duration::from_secs(poll_interval_secs.unwrap_or(3));
318    let timeout = Duration::from_secs(timeout_secs.unwrap_or(600));
319    let start = std::time::Instant::now();
320
321    let client = build_pooled_client(Some(10));
322
323    loop {
324        if start.elapsed() >= timeout {
325            return Err(CoreError::Timeout(
326                "device auth timed out before credentials were returned".to_string(),
327            ));
328        }
329
330        let resp = client
331            .post(&url)
332            .json(&serde_json::json!({ "device_code": device_code }))
333            .send()
334            .await;
335
336        match resp {
337            Ok(r) if r.status().is_success() => {
338                let data: DeviceAuthResponse = r
339                    .json()
340                    .await
341                    .map_err(|e| CoreError::Authentication(format!("invalid JSON: {}", e)))?;
342
343                return Ok(extract_credentials(data));
344            }
345            Ok(r) if r.status().as_u16() == 404 || r.status().as_u16() == 410 => {
346                return Err(CoreError::Authentication(
347                    "device code expired or was revoked".to_string(),
348                ));
349            }
350            _ => {
351                // Continue polling
352                tokio::time::sleep(poll_interval).await;
353            }
354        }
355    }
356}
357
358/// Extract credentials from device auth response, handling legacy format.
359fn extract_credentials(data: DeviceAuthResponse) -> HashMap<String, String> {
360    let mut result = HashMap::new();
361
362    // Get SYNTH_API_KEY
363    let synth_key = data.synth_api_key.filter(|s| !s.is_empty()).or_else(|| {
364        data.keys
365            .as_ref()
366            .and_then(|k| k.get("synth").cloned())
367            .filter(|s| !s.is_empty())
368    });
369
370    if let Some(key) = synth_key {
371        result.insert(ENV_API_KEY.to_string(), key);
372    }
373
374    // Get ENVIRONMENT_API_KEY
375    let env_key = data
376        .environment_api_key
377        .filter(|s| !s.is_empty())
378        .or_else(|| {
379            data.keys.as_ref().and_then(|k| {
380                k.get("rl_env")
381                    .or_else(|| k.get("environment_api_key"))
382                    .cloned()
383                    .filter(|s| !s.is_empty())
384            })
385        });
386
387    if let Some(key) = env_key {
388        result.insert(ENV_ENVIRONMENT_API_KEY.to_string(), key);
389    }
390
391    result
392}
393
394/// Mint a demo API key from the backend.
395///
396/// # Arguments
397///
398/// * `backend_url` - Backend URL (defaults to https://api.usesynth.ai)
399/// * `ttl_hours` - Key TTL in hours (default 4)
400pub async fn mint_demo_key(
401    backend_url: Option<&str>,
402    ttl_hours: Option<u32>,
403) -> Result<String, CoreError> {
404    let base = backend_url
405        .map(|url| url.to_string())
406        .unwrap_or_else(backend_url_base)
407        .trim_end_matches('/')
408        .to_string();
409    let url = format!("{}/api/demo/keys", base);
410    let ttl = ttl_hours.unwrap_or(4);
411
412    let client = build_pooled_client(Some(30));
413    let resp = client
414        .post(&url)
415        .json(&serde_json::json!({ "ttl_hours": ttl }))
416        .send()
417        .await
418        .map_err(|e| CoreError::Authentication(format!("failed to mint demo key: {}", e)))?;
419
420    if !resp.status().is_success() {
421        let status = resp.status();
422        let body = resp.text().await.unwrap_or_default();
423        return Err(CoreError::Authentication(format!(
424            "demo key minting failed ({}): {}",
425            status,
426            body.trim()
427        )));
428    }
429
430    let data: Value = resp
431        .json()
432        .await
433        .map_err(|e| CoreError::Authentication(format!("invalid JSON: {}", e)))?;
434
435    data["api_key"]
436        .as_str()
437        .filter(|s| !s.is_empty())
438        .map(|s| s.to_string())
439        .ok_or_else(|| CoreError::Authentication("no api_key in response".to_string()))
440}
441
442/// Get or mint an API key.
443///
444/// Resolution order:
445/// 1. Environment variable
446/// 2. Config file
447/// 3. Mint demo key (if allow_mint is true)
448///
449/// # Arguments
450///
451/// * `backend_url` - Backend URL for minting
452/// * `allow_mint` - Whether to mint a demo key if not found
453pub async fn get_or_mint_api_key(
454    backend_url: Option<&str>,
455    allow_mint: bool,
456) -> Result<String, CoreError> {
457    // Try environment and config first
458    if let Some(key) = get_api_key(None) {
459        return Ok(key);
460    }
461
462    // Mint if allowed
463    if allow_mint {
464        return mint_demo_key(backend_url, None).await;
465    }
466
467    Err(CoreError::Authentication(
468        "SYNTH_API_KEY is required but missing".to_string(),
469    ))
470}
471
472/// Mask a string for display (shows first 8 chars + "...").
473pub fn mask_str(s: &str) -> String {
474    if s.len() <= 8 {
475        "*".repeat(s.len())
476    } else {
477        format!("{}...", &s[..8])
478    }
479}
480
481/// Load credentials from config file and set environment variables.
482///
483/// This hydrates environment variables from the stored config file,
484/// making credentials available to the current process.
485///
486/// # Returns
487///
488/// Map of variable names to values that were set.
489pub fn load_user_env() -> Result<HashMap<String, String>, CoreError> {
490    load_user_env_with(true)
491}
492
493/// Load credentials from config and container config, setting env vars.
494pub fn load_user_env_with(override_env: bool) -> Result<HashMap<String, String>, CoreError> {
495    let mut applied: HashMap<String, String> = HashMap::new();
496
497    let mut apply = |mapping: &HashMap<String, Value>| {
498        for (k, v) in mapping {
499            if v.is_null() {
500                continue;
501            }
502            let value = if let Some(s) = v.as_str() {
503                s.to_string()
504            } else {
505                v.to_string()
506            };
507            if override_env || env::var(k).is_err() {
508                env::set_var(k, &value);
509            }
510            applied.insert(k.clone(), value);
511        }
512    };
513
514    let config = load_user_config()?;
515    apply(&config);
516
517    // Load container config (container entries)
518    let container_path = get_container_config_path();
519    if container_path.exists() {
520        let raw = fs::read_to_string(&container_path)
521            .map_err(|e| CoreError::Config(format!("failed to read container config: {}", e)))?;
522        if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&raw) {
523            if let Some(Value::Object(apps)) = map.get("apps") {
524                if let Some(entry) = select_container_entry(apps) {
525                    if let Some(Value::Object(modal)) = entry.get("modal") {
526                        let mut modal_map = HashMap::new();
527                        if let Some(v) = modal.get("base_url") {
528                            modal_map.insert("CONTAINER_BASE_URL".to_string(), v.clone());
529                        }
530                        if let Some(v) = modal.get("app_name") {
531                            modal_map.insert("CONTAINER_NAME".to_string(), v.clone());
532                        }
533                        if let Some(v) = modal.get("secret_name") {
534                            modal_map.insert("CONTAINER_SECRET_NAME".to_string(), v.clone());
535                        }
536                        apply(&modal_map);
537                    }
538                    if let Some(Value::Object(secrets)) = entry.get("secrets") {
539                        let mut secrets_map = HashMap::new();
540                        if let Some(v) = secrets.get("environment_api_key") {
541                            secrets_map.insert("ENVIRONMENT_API_KEY".to_string(), v.clone());
542                            secrets_map.insert("DEV_ENVIRONMENT_API_KEY".to_string(), v.clone());
543                        }
544                        apply(&secrets_map);
545                    }
546                }
547            }
548        }
549    }
550
551    Ok(applied)
552}
553
554fn select_container_entry(
555    apps: &serde_json::Map<String, Value>,
556) -> Option<&serde_json::Map<String, Value>> {
557    if apps.is_empty() {
558        return None;
559    }
560
561    if let Ok(cwd) = env::current_dir() {
562        let cwd_str = cwd.to_string_lossy().to_string();
563        if let Some(Value::Object(entry)) = apps.get(&cwd_str) {
564            return Some(entry);
565        }
566    }
567
568    let mut best: Option<&serde_json::Map<String, Value>> = None;
569    let mut best_ts = String::new();
570    for (_key, entry) in apps {
571        if let Value::Object(map) = entry {
572            let ts = map
573                .get("last_used")
574                .and_then(|v| v.as_str())
575                .unwrap_or("")
576                .to_string();
577            if ts > best_ts {
578                best_ts = ts;
579                best = Some(map);
580            }
581        }
582    }
583    best
584}
585
586/// Write content to a file atomically using temp file + rename.
587///
588/// This ensures that the file is either fully written or not at all,
589/// preventing partial writes from crashes. Also sets secure permissions
590/// on Unix systems.
591fn write_atomic(path: &Path, content: &str) -> Result<(), CoreError> {
592    use std::io::Write;
593
594    let dir = path
595        .parent()
596        .ok_or_else(|| CoreError::Config("no parent directory".to_string()))?;
597
598    // Create temp file in same directory for atomic rename
599    let mut temp = tempfile::NamedTempFile::new_in(dir)
600        .map_err(|e| CoreError::Config(format!("failed to create temp file: {}", e)))?;
601
602    // Set permissions before writing on Unix
603    #[cfg(unix)]
604    {
605        use std::os::unix::fs::PermissionsExt;
606        let perms = fs::Permissions::from_mode(0o600);
607        let _ = temp.as_file().set_permissions(perms);
608    }
609
610    // Write content
611    temp.write_all(content.as_bytes())
612        .map_err(|e| CoreError::Config(format!("failed to write: {}", e)))?;
613
614    // Sync to disk
615    temp.as_file()
616        .sync_all()
617        .map_err(|e| CoreError::Config(format!("failed to sync: {}", e)))?;
618
619    // Atomic rename
620    temp.persist(path)
621        .map_err(|e| CoreError::Config(format!("failed to persist: {}", e)))?;
622
623    Ok(())
624}
625
626/// Store credentials securely using atomic file operations.
627///
628/// This is an enhanced version of `store_credentials` that uses
629/// atomic writes for better security and crash safety.
630pub fn store_credentials_atomic(
631    credentials: &HashMap<String, String>,
632    config_path: Option<&Path>,
633) -> Result<(), CoreError> {
634    let path = config_path
635        .map(|p| p.to_path_buf())
636        .unwrap_or_else(get_config_path);
637
638    // Create parent directory with secure permissions
639    if let Some(parent) = path.parent() {
640        fs::create_dir_all(parent)
641            .map_err(|e| CoreError::Config(format!("failed to create config dir: {}", e)))?;
642
643        #[cfg(unix)]
644        {
645            use std::os::unix::fs::PermissionsExt;
646            let perms = fs::Permissions::from_mode(0o700);
647            let _ = fs::set_permissions(parent, perms);
648        }
649    }
650
651    // Load existing config and merge
652    let mut existing = load_credentials(Some(&path)).unwrap_or_default();
653    for (k, v) in credentials {
654        existing.insert(k.clone(), v.clone());
655    }
656
657    // Serialize
658    let content = serde_json::to_string_pretty(&existing)
659        .map_err(|e| CoreError::Config(format!("failed to serialize: {}", e)))?;
660
661    // Write atomically
662    write_atomic(&path, &content)
663}
664
665/// Run the interactive device authentication setup.
666///
667/// This initiates the device auth flow, opens the browser, and
668/// waits for the user to authenticate.
669///
670/// # Arguments
671///
672/// * `open_browser` - Whether to automatically open the browser
673///
674/// # Example
675///
676/// ```ignore
677/// // Run setup with automatic browser opening
678/// run_setup(true).await?;
679/// ```
680pub async fn run_setup(open_browser: bool) -> Result<(), CoreError> {
681    let session = init_device_auth(None).await?;
682
683    println!("Please visit the following URL to authenticate:");
684    println!("  {}", session.verification_uri);
685
686    if open_browser {
687        if let Err(_) = open::that(&session.verification_uri) {
688            println!("(Could not open browser automatically)");
689        }
690    }
691
692    println!("\nWaiting for authentication...");
693
694    let creds = poll_device_token(None, &session.device_code, None, None).await?;
695
696    // Store using atomic write
697    store_credentials_atomic(&creds, None)?;
698
699    let config_path = get_config_path();
700    println!("\nCredentials saved to: {}", config_path.display());
701    println!("You can now use the Synth API.");
702
703    Ok(())
704}
705
706/// Get current Unix timestamp.
707fn now_timestamp() -> f64 {
708    SystemTime::now()
709        .duration_since(UNIX_EPOCH)
710        .map(|d| d.as_secs_f64())
711        .unwrap_or(0.0)
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_get_config_path() {
720        let path = get_config_path();
721        assert!(path.ends_with("user_config.json"));
722        assert!(path.to_string_lossy().contains(".synth-ai"));
723    }
724
725    #[test]
726    fn test_mask_str() {
727        assert_eq!(mask_str("short"), "*****");
728        assert_eq!(mask_str("sk_live_1234567890"), "sk_live_...");
729    }
730
731    #[test]
732    fn test_extract_credentials() {
733        // Modern format
734        let data = DeviceAuthResponse {
735            synth_api_key: Some("sk_test_123".to_string()),
736            environment_api_key: Some("env_test_456".to_string()),
737            keys: None,
738        };
739        let creds = extract_credentials(data);
740        assert_eq!(creds.get("SYNTH_API_KEY"), Some(&"sk_test_123".to_string()));
741        assert_eq!(
742            creds.get("ENVIRONMENT_API_KEY"),
743            Some(&"env_test_456".to_string())
744        );
745
746        // Legacy format
747        let mut legacy_keys = HashMap::new();
748        legacy_keys.insert("synth".to_string(), "sk_legacy".to_string());
749        legacy_keys.insert("rl_env".to_string(), "env_legacy".to_string());
750
751        let data = DeviceAuthResponse {
752            synth_api_key: None,
753            environment_api_key: None,
754            keys: Some(legacy_keys),
755        };
756        let creds = extract_credentials(data);
757        assert_eq!(creds.get("SYNTH_API_KEY"), Some(&"sk_legacy".to_string()));
758        assert_eq!(
759            creds.get("ENVIRONMENT_API_KEY"),
760            Some(&"env_legacy".to_string())
761        );
762    }
763}