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