Skip to main content

romm_cli/commands/
auth.rs

1//! Authentication command group (`romm-cli auth ...`).
2//!
3//! This is intentionally layered on top of the existing config/keyring logic in
4//! `src/config.rs` so users can rotate credentials without re-entering the ROM
5//! path / base URL.
6
7use anyhow::{anyhow, Context, Result};
8use clap::{Args, Subcommand};
9use dialoguer::{Input, Password, Select};
10use serde_json::json;
11use std::fs;
12use std::io::Read;
13
14use crate::client::RommClient;
15use crate::commands::OutputFormat;
16use crate::config::{
17    disk_has_unresolved_keyring_sentinel, is_keyring_placeholder, load_config, persist_user_config,
18    read_user_config_json_from_disk, user_config_json_path, AuthConfig, Config,
19    KEYRING_SECRET_PLACEHOLDER,
20};
21use crate::endpoints::client_tokens::ExchangeClientToken;
22
23/// Top-level `romm-cli auth` command group.
24#[derive(Args, Debug, Clone)]
25pub struct AuthCommand {
26    #[command(subcommand)]
27    pub action: AuthAction,
28}
29
30/// Specific action within `romm-cli auth`.
31#[derive(Subcommand, Debug, Clone)]
32pub enum AuthAction {
33    /// Set/rotate authentication credentials (Bearer, Basic, API key, or pairing code).
34    Login(AuthLoginCommand),
35    /// Remove stored authentication (leaves non-auth config untouched).
36    Logout,
37    /// Show current authentication mode and where it comes from (env/config/keyring).
38    Status,
39}
40
41/// Options for `romm-cli auth login`.
42///
43/// If no auth flags are provided, the command runs interactively.
44#[derive(Args, Debug, Clone)]
45pub struct AuthLoginCommand {
46    /// API token (Bearer). Skips interactive prompts.
47    #[arg(long)]
48    pub token: Option<String>,
49
50    /// Read API token (Bearer) from UTF-8 file. Use '-' for stdin.
51    #[arg(long)]
52    pub token_file: Option<String>,
53
54    /// Basic auth username.
55    #[arg(long)]
56    pub username: Option<String>,
57
58    /// Basic auth password (discouraged: visible in process list).
59    #[arg(long)]
60    pub password: Option<String>,
61
62    /// Read Basic auth password from a UTF-8 file. Use '-' for stdin.
63    #[arg(long)]
64    pub password_file: Option<String>,
65
66    /// API key header name (e.g. X-API-Key).
67    #[arg(long)]
68    pub api_key_header: Option<String>,
69
70    /// API key value.
71    #[arg(long)]
72    pub api_key: Option<String>,
73
74    /// Web UI pairing code (8 characters).
75    #[arg(long)]
76    pub pairing_code: Option<String>,
77}
78
79fn env_nonempty(key: &str) -> Option<String> {
80    std::env::var(key)
81        .ok()
82        .map(|s| s.trim().to_string())
83        .filter(|s| !s.is_empty())
84}
85
86fn read_secret_from_path_or_stdin(path: &str) -> Result<String> {
87    let mut content = String::new();
88    if path == "-" {
89        std::io::stdin()
90            .read_to_string(&mut content)
91            .context("read secret from stdin")?;
92    } else {
93        content =
94            fs::read_to_string(path).with_context(|| format!("read secret from file {}", path))?;
95    }
96    let trimmed = content.trim();
97    if trimmed.is_empty() {
98        return Err(anyhow!("secret read from {} is empty", path));
99    }
100    Ok(trimmed.to_string())
101}
102
103fn disk_config_or_die() -> Result<Config> {
104    read_user_config_json_from_disk().ok_or_else(|| {
105        anyhow!(
106            "Could not read user config.json. Run `romm-cli init` first (or ensure your config exists)."
107        )
108    })
109}
110
111fn preserve_non_auth_fields_for_persist() -> Result<(String, String, bool, std::path::PathBuf)> {
112    let disk = disk_config_or_die()?;
113    let config_path =
114        user_config_json_path().ok_or_else(|| anyhow!("Could not resolve config path"))?;
115    Ok((
116        disk.base_url,
117        disk.download_dir,
118        disk.use_https,
119        config_path,
120    ))
121}
122
123async fn persist_auth_from_login(auth: Option<AuthConfig>, client: &RommClient) -> Result<()> {
124    let (base_url, download_dir, use_https, config_path) = preserve_non_auth_fields_for_persist()?;
125
126    // Compute the human-readable auth mode before persisting, since `auth` is moved.
127    let mode = match &auth {
128        None => "none",
129        Some(AuthConfig::Basic { .. }) => "basic",
130        Some(AuthConfig::Bearer { .. }) => "bearer",
131        Some(AuthConfig::ApiKey { .. }) => "api-key",
132    };
133
134    persist_user_config(&base_url, &download_dir, use_https, auth)?;
135
136    if config_path.exists() {
137        println!("Auth updated: {mode} (wrote {})", config_path.display());
138    } else {
139        // Should not happen (persist_user_config creates the directory), but keep it safe.
140        println!("Auth updated: {mode}");
141    }
142
143    // Keep the client reference "used" in this helper for future extensions
144    // (e.g. verification) without changing function signature.
145    let _ = client.verbose();
146    Ok(())
147}
148
149async fn login_interactive(cmd: &AuthLoginCommand, client: &RommClient) -> Result<AuthConfig> {
150    // If any login flags are present, we do not consider it "interactive".
151    let has_flags = cmd.token.is_some()
152        || cmd.token_file.is_some()
153        || cmd.username.is_some()
154        || cmd.password.is_some()
155        || cmd.password_file.is_some()
156        || cmd.api_key_header.is_some()
157        || cmd.api_key.is_some()
158        || cmd.pairing_code.is_some();
159    if has_flags {
160        return Err(anyhow!(
161            "internal error: interactive auth called with flags present"
162        ));
163    }
164
165    let items = vec![
166        "Basic (username + password)",
167        "API Token (Bearer)",
168        "API key in custom header",
169        "Pair with Web UI (8-character code)",
170    ];
171    let idx = Select::new()
172        .with_prompt("Authentication")
173        .items(&items)
174        .default(1)
175        .interact()?;
176
177    match idx {
178        0 => {
179            let username: String = Input::new().with_prompt("Username").interact_text()?;
180            let password = Password::new().with_prompt("Password").interact()?;
181            Ok(AuthConfig::Basic {
182                username: username.trim().to_string(),
183                password,
184            })
185        }
186        1 => {
187            let token = Password::new().with_prompt("API Token").interact()?;
188            Ok(AuthConfig::Bearer { token })
189        }
190        2 => {
191            let header: String = Input::new()
192                .with_prompt("Header name (e.g. X-API-Key)")
193                .interact_text()?;
194            let key = Password::new().with_prompt("API key value").interact()?;
195            Ok(AuthConfig::ApiKey {
196                header: header.trim().to_string(),
197                key,
198            })
199        }
200        3 => {
201            let code: String = Input::new()
202                .with_prompt("8-character pairing code")
203                .interact_text()?;
204
205            // Pairing-code exchange should not depend on the current auth mode
206            // (because we are rotating it). Use an unauthenticated client.
207            let disk = disk_config_or_die()?;
208            let temp_config = Config {
209                base_url: disk.base_url,
210                download_dir: disk.download_dir,
211                use_https: disk.use_https,
212                auth: None,
213            };
214            let unauth_client = RommClient::new(&temp_config, client.verbose())?;
215
216            let endpoint = ExchangeClientToken { code };
217            let response = unauth_client
218                .call(&endpoint)
219                .await
220                .context("failed to exchange pairing code")?;
221
222            Ok(AuthConfig::Bearer {
223                token: response.raw_token,
224            })
225        }
226        _ => Err(anyhow!("unreachable login auth choice")),
227    }
228}
229
230fn env_hint_auth_mode() -> Option<&'static str> {
231    // Mirrors `load_config` auth precedence order at a high level.
232    if env_nonempty("API_USERNAME").is_some() || env_nonempty("API_PASSWORD").is_some() {
233        return Some("basic");
234    }
235    if env_nonempty("API_KEY").is_some() || env_nonempty("API_KEY_HEADER").is_some() {
236        return Some("api-key");
237    }
238    if env_nonempty("API_TOKEN").is_some()
239        || env_nonempty("ROMM_TOKEN_FILE").is_some()
240        || env_nonempty("API_TOKEN_FILE").is_some()
241    {
242        return Some("bearer");
243    }
244    None
245}
246
247fn disk_secret_unresolved_placeholder(auth: &Option<AuthConfig>) -> bool {
248    match auth {
249        None => false,
250        Some(AuthConfig::Basic { password, .. }) => is_keyring_placeholder(password),
251        Some(AuthConfig::Bearer { token }) => is_keyring_placeholder(token),
252        Some(AuthConfig::ApiKey { key, .. }) => is_keyring_placeholder(key),
253    }
254}
255
256fn auth_mode_string(auth: &Option<AuthConfig>) -> &'static str {
257    match auth {
258        None => "none",
259        Some(AuthConfig::Basic { .. }) => "basic",
260        Some(AuthConfig::Bearer { .. }) => "bearer",
261        Some(AuthConfig::ApiKey { .. }) => "api-key",
262    }
263}
264
265/// Execute `romm-cli auth ...`.
266pub async fn handle(cmd: AuthCommand, client: &RommClient, format: OutputFormat) -> Result<()> {
267    match cmd.action {
268        AuthAction::Login(login) => {
269            // Non-interactive fast path: infer auth from provided flags.
270            let has_flags = login.token.is_some()
271                || login.token_file.is_some()
272                || login.username.is_some()
273                || login.password.is_some()
274                || login.password_file.is_some()
275                || login.api_key_header.is_some()
276                || login.api_key.is_some()
277                || login.pairing_code.is_some();
278
279            let auth = if has_flags {
280                // Enforce "single auth mode" for predictable behavior.
281                let mut modes = Vec::new();
282                if login.pairing_code.is_some() {
283                    modes.push("pairing-code");
284                }
285                if login.token.is_some() || login.token_file.is_some() {
286                    modes.push("bearer");
287                }
288                if login.username.is_some()
289                    || login.password.is_some()
290                    || login.password_file.is_some()
291                {
292                    modes.push("basic");
293                }
294                if login.api_key_header.is_some() || login.api_key.is_some() {
295                    modes.push("api-key");
296                }
297
298                if modes.is_empty() {
299                    return Err(anyhow!("no authentication fields found"));
300                }
301                if modes.len() != 1 {
302                    return Err(anyhow!(
303                        "Specify exactly one authentication mode, got: {}",
304                        modes.join(", ")
305                    ));
306                }
307
308                if let Some(code) = login.pairing_code {
309                    let disk = disk_config_or_die()?;
310                    let temp_config = Config {
311                        base_url: disk.base_url,
312                        download_dir: disk.download_dir,
313                        use_https: disk.use_https,
314                        auth: None,
315                    };
316                    let unauth_client = RommClient::new(&temp_config, client.verbose())?;
317                    let endpoint = ExchangeClientToken { code };
318                    let response = unauth_client
319                        .call(&endpoint)
320                        .await
321                        .context("failed to exchange pairing code")?;
322                    AuthConfig::Bearer {
323                        token: response.raw_token,
324                    }
325                } else if login.token.is_some() || login.token_file.is_some() {
326                    let token = match (login.token, login.token_file) {
327                        (Some(_), Some(_)) => {
328                            return Err(anyhow!(
329                                "Provide either --token or --token-file, not both"
330                            ));
331                        }
332                        (Some(t), None) => t,
333                        (None, Some(f)) => read_secret_from_path_or_stdin(&f)?,
334                        (None, None) => unreachable!("checked by flags"),
335                    };
336                    AuthConfig::Bearer { token }
337                } else if login.api_key_header.is_some() || login.api_key.is_some() {
338                    let header = login.api_key_header.ok_or_else(|| {
339                        anyhow!("--api-key-header is required when using --api-key")
340                    })?;
341                    let key = login.api_key.ok_or_else(|| {
342                        anyhow!("--api-key is required when using --api-key-header")
343                    })?;
344                    AuthConfig::ApiKey {
345                        header: header.trim().to_string(),
346                        key,
347                    }
348                } else {
349                    // Basic
350                    let username = login
351                        .username
352                        .ok_or_else(|| anyhow!("--username is required for basic auth"))?;
353                    let password = match (login.password, login.password_file) {
354                        (Some(p), None) => p,
355                        (None, Some(f)) => read_secret_from_path_or_stdin(&f)?,
356                        (None, None) => {
357                            return Err(anyhow!(
358                                "--password or --password-file is required for basic auth"
359                            ))
360                        }
361                        (Some(_), Some(_)) => {
362                            return Err(anyhow!(
363                                "Provide either --password or --password-file, not both"
364                            ))
365                        }
366                    };
367                    AuthConfig::Basic {
368                        username: username.trim().to_string(),
369                        password,
370                    }
371                }
372            } else {
373                login_interactive(&login, client).await?
374            };
375
376            persist_auth_from_login(Some(auth), client).await?;
377            Ok(())
378        }
379
380        AuthAction::Logout => {
381            persist_auth_from_login(None, client).await?;
382            Ok(())
383        }
384
385        AuthAction::Status => {
386            let effective = load_config()?;
387            let disk = read_user_config_json_from_disk();
388
389            let effective_mode = auth_mode_string(&effective.auth);
390            let disk_auth = disk.as_ref().and_then(|c| c.auth.clone());
391            let disk_mode = auth_mode_string(&disk_auth);
392            let disk_unresolved = disk_secret_unresolved_placeholder(&disk_auth);
393            let unresolved_keyring_sentinel = disk_has_unresolved_keyring_sentinel(&effective);
394
395            let env_mode = env_hint_auth_mode();
396            let env_hints = json!({
397                "API_USERNAME_set": std::env::var("API_USERNAME").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
398                "API_PASSWORD_set": std::env::var("API_PASSWORD").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
399                "API_TOKEN_set": std::env::var("API_TOKEN").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
400                "ROMM_TOKEN_FILE_set": std::env::var("ROMM_TOKEN_FILE").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
401                "API_TOKEN_FILE_set": std::env::var("API_TOKEN_FILE").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
402                "API_KEY_HEADER_set": std::env::var("API_KEY_HEADER").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
403                "API_KEY_set": std::env::var("API_KEY").ok().map(|v| !v.trim().is_empty()).unwrap_or(false),
404            });
405
406            match format {
407                OutputFormat::Json => {
408                    let out = json!({
409                        "effective": { "mode": effective_mode },
410                        "disk": {
411                            "mode": disk_mode,
412                            "secret_unresolved_in_keyring": disk_unresolved,
413                        },
414                        "env_hint_mode": env_mode,
415                        "env": env_hints,
416                        "keyring_resolution_status": {
417                            "unresolved_keyring_sentinel": unresolved_keyring_sentinel,
418                        }
419                    });
420                    println!("{}", serde_json::to_string_pretty(&out)?);
421                }
422                OutputFormat::Text => {
423                    println!("Auth (effective): {effective_mode}");
424                    println!("Auth (disk): {disk_mode}");
425                    if disk_auth.is_some() {
426                        println!(
427                            "Disk secret unresolved sentinel: {}",
428                            if disk_unresolved { "yes" } else { "no" }
429                        );
430                    } else {
431                        println!("Disk config: not found");
432                    }
433                    if unresolved_keyring_sentinel {
434                        println!(
435                            "Keyring lookup failed: config contains `{}` but effective auth is missing.",
436                            KEYRING_SECRET_PLACEHOLDER
437                        );
438                    }
439                    if let Some(m) = env_mode {
440                        println!("Env auth hint (not showing secrets): {m}");
441                    } else {
442                        println!("Env auth hint: none");
443                    }
444                }
445            }
446            Ok(())
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::commands::{Cli, Commands};
455    use clap::Parser;
456
457    struct TestEnv {
458        dir: std::path::PathBuf,
459        _guard: std::sync::MutexGuard<'static, ()>,
460    }
461
462    impl TestEnv {
463        fn new() -> Self {
464            let guard = crate::config::test_env_lock()
465                .lock()
466                .unwrap_or_else(|e| e.into_inner());
467
468            let mut unique = std::time::SystemTime::now()
469                .duration_since(std::time::UNIX_EPOCH)
470                .unwrap()
471                .as_nanos()
472                .to_string();
473            unique.push_str("-auth");
474
475            let dir = std::env::temp_dir().join(format!("romm-cli-auth-test-{}", unique));
476            let _ = std::fs::remove_dir_all(&dir);
477            std::fs::create_dir_all(&dir).expect("create test config dir");
478
479            clear_env();
480            std::env::set_var("ROMM_TEST_CONFIG_DIR", &dir);
481            Self { dir, _guard: guard }
482        }
483    }
484
485    impl Drop for TestEnv {
486        fn drop(&mut self) {
487            clear_env();
488            let _ = std::fs::remove_dir_all(&self.dir);
489            std::env::remove_var("ROMM_TEST_CONFIG_DIR");
490        }
491    }
492
493    fn clear_env() {
494        for key in [
495            "ROMM_TEST_CONFIG_DIR",
496            "API_BASE_URL",
497            "ROMM_ROMS_DIR",
498            "ROMM_DOWNLOAD_DIR",
499            "API_USE_HTTPS",
500            "API_USERNAME",
501            "API_PASSWORD",
502            "API_TOKEN",
503            "ROMM_TOKEN_FILE",
504            "API_TOKEN_FILE",
505            "API_KEY",
506            "API_KEY_HEADER",
507        ] {
508            std::env::remove_var(key);
509        }
510    }
511
512    fn write_disk_config(path: &std::path::Path, disk_auth: Option<AuthConfig>) {
513        fs::create_dir_all(path).unwrap();
514        let cfg = Config {
515            base_url: "https://disk.example".to_string(),
516            download_dir: "/disk/dl".to_string(),
517            use_https: true,
518            auth: disk_auth,
519        };
520        let content = serde_json::to_string_pretty(&cfg).unwrap();
521        fs::write(path.join("config.json"), content).unwrap();
522    }
523
524    #[test]
525    fn parse_auth_logout() {
526        let cli = Cli::parse_from(["romm-cli", "auth", "logout"]);
527        let Commands::Auth(cmd) = cli.command else {
528            panic!("expected auth command");
529        };
530        assert!(matches!(cmd.action, AuthAction::Logout));
531    }
532
533    #[test]
534    fn auth_status_unresolved_sentinel_detected_from_disk() {
535        let env = TestEnv::new();
536        write_disk_config(
537            &env.dir,
538            Some(AuthConfig::Bearer {
539                token: KEYRING_SECRET_PLACEHOLDER.to_string(),
540            }),
541        );
542
543        let effective = Config {
544            base_url: String::new(),
545            download_dir: String::new(),
546            use_https: true,
547            auth: None,
548        };
549
550        assert!(disk_has_unresolved_keyring_sentinel(&effective));
551    }
552
553    #[test]
554    fn auth_login_preserves_disk_non_auth_fields_even_with_env_overrides() {
555        let env = TestEnv::new();
556        write_disk_config(&env.dir, None);
557
558        std::env::set_var("API_BASE_URL", "https://env.example");
559        std::env::set_var("ROMM_ROMS_DIR", "/env/dl");
560        std::env::set_var("API_USE_HTTPS", "false");
561
562        // Use the sentinel value to avoid keyring interaction in tests.
563        let disk_auth = Some(AuthConfig::Bearer {
564            token: KEYRING_SECRET_PLACEHOLDER.to_string(),
565        });
566
567        let tmp_client = RommClient::new(
568            &Config {
569                base_url: "https://dummy.example".to_string(),
570                download_dir: "/tmp".to_string(),
571                use_https: true,
572                auth: None,
573            },
574            false,
575        )
576        .unwrap();
577
578        // This helper is async; execute with a runtime.
579        let rt = tokio::runtime::Runtime::new().unwrap();
580        rt.block_on(persist_auth_from_login(disk_auth, &tmp_client))
581            .unwrap();
582
583        let saved = read_user_config_json_from_disk().unwrap();
584        assert_eq!(saved.base_url, "https://disk.example");
585        assert_eq!(saved.download_dir, "/disk/dl");
586        assert!(saved.use_https);
587        match saved.auth {
588            Some(AuthConfig::Bearer { token }) => {
589                assert!(is_keyring_placeholder(&token));
590            }
591            _ => panic!("expected bearer auth on disk"),
592        }
593    }
594
595    #[test]
596    fn auth_logout_clears_auth_but_preserves_non_auth_fields() {
597        let env = TestEnv::new();
598        write_disk_config(
599            &env.dir,
600            Some(AuthConfig::Bearer {
601                token: "some-token".to_string(),
602            }),
603        );
604
605        let tmp_client = RommClient::new(
606            &Config {
607                base_url: "https://dummy.example".to_string(),
608                download_dir: "/tmp".to_string(),
609                use_https: true,
610                auth: None,
611            },
612            false,
613        )
614        .unwrap();
615
616        let rt = tokio::runtime::Runtime::new().unwrap();
617        rt.block_on(persist_auth_from_login(None, &tmp_client))
618            .unwrap();
619
620        let saved = read_user_config_json_from_disk().unwrap();
621        assert_eq!(saved.base_url, "https://disk.example");
622        assert_eq!(saved.download_dir, "/disk/dl");
623        assert!(saved.use_https);
624        assert!(saved.auth.is_none());
625    }
626}