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