1use 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#[derive(Args, Debug, Clone)]
26pub struct AuthCommand {
27 #[command(subcommand)]
28 pub action: AuthAction,
29}
30
31#[derive(Subcommand, Debug, Clone)]
33pub enum AuthAction {
34 Login(AuthLoginCommand),
36 Logout,
38 Status,
40}
41
42#[derive(Args, Debug, Clone)]
46pub struct AuthLoginCommand {
47 #[arg(long)]
49 pub token: Option<String>,
50
51 #[arg(long)]
53 pub token_file: Option<String>,
54
55 #[arg(long)]
57 pub username: Option<String>,
58
59 #[arg(long)]
61 pub password: Option<String>,
62
63 #[arg(long)]
65 pub password_file: Option<String>,
66
67 #[arg(long)]
69 pub api_key_header: Option<String>,
70
71 #[arg(long)]
73 pub api_key: Option<String>,
74
75 #[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 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 println!("Auth updated: {mode}");
133 }
134
135 let _ = client.verbose();
138 Ok(())
139}
140
141async fn login_interactive(cmd: &AuthLoginCommand, client: &RommClient) -> Result<AuthConfig> {
142 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 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 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
252pub 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 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 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 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 tui_layout: Default::default(),
511 };
512 let content = serde_json::to_string_pretty(&cfg).unwrap();
513 fs::write(path.join("config.json"), content).unwrap();
514 }
515
516 #[test]
517 fn parse_auth_logout() {
518 let cli = Cli::parse_from(["romm-cli", "auth", "logout"]);
519 let Commands::Auth(cmd) = cli.command else {
520 panic!("expected auth command");
521 };
522 assert!(matches!(cmd.action, AuthAction::Logout));
523 }
524
525 #[test]
526 fn auth_status_unresolved_sentinel_detected_from_disk() {
527 let env = TestEnv::new();
528 write_disk_config(
529 &env.dir,
530 Some(AuthConfig::Bearer {
531 token: KEYRING_SECRET_PLACEHOLDER.to_string(),
532 }),
533 );
534
535 let effective = Config {
536 base_url: String::new(),
537 download_dir: String::new(),
538 use_https: true,
539 auth: None,
540 extras_defaults: crate::config::ExtrasDefaults::default(),
541 save_sync: Default::default(),
542 roms_layout: Default::default(),
543 theme: crate::config::default_theme_id(),
544 tui_layout: Default::default(),
545 };
546
547 assert!(disk_has_unresolved_keyring_sentinel(&effective));
548 }
549
550 #[test]
551 fn auth_login_preserves_disk_non_auth_fields_even_with_env_overrides() {
552 let env = TestEnv::new();
553 write_disk_config(&env.dir, None);
554
555 std::env::set_var("API_BASE_URL", "https://env.example");
556 std::env::set_var("ROMM_ROMS_DIR", "/env/dl");
557 std::env::set_var("API_USE_HTTPS", "false");
558
559 let disk_auth = Some(AuthConfig::Bearer {
561 token: KEYRING_SECRET_PLACEHOLDER.to_string(),
562 });
563
564 let tmp_client = RommClient::new(
565 &Config {
566 base_url: "https://dummy.example".to_string(),
567 download_dir: "/tmp".to_string(),
568 use_https: true,
569 auth: None,
570 extras_defaults: crate::config::ExtrasDefaults::default(),
571 save_sync: Default::default(),
572 roms_layout: Default::default(),
573 theme: crate::config::default_theme_id(),
574 tui_layout: Default::default(),
575 },
576 false,
577 )
578 .unwrap();
579
580 let rt = tokio::runtime::Runtime::new().unwrap();
582 rt.block_on(persist_auth_from_login(disk_auth, &tmp_client))
583 .unwrap();
584
585 let saved = read_user_config_json_from_disk().unwrap();
586 assert_eq!(saved.base_url, "https://disk.example");
587 assert_eq!(saved.download_dir, "/disk/dl");
588 assert!(saved.use_https);
589 match saved.auth {
590 Some(AuthConfig::Bearer { token }) => {
591 assert!(is_keyring_placeholder(&token));
592 }
593 _ => panic!("expected bearer auth on disk"),
594 }
595 }
596
597 #[test]
598 fn auth_logout_clears_auth_but_preserves_non_auth_fields() {
599 let env = TestEnv::new();
600 write_disk_config(
601 &env.dir,
602 Some(AuthConfig::Bearer {
603 token: "some-token".to_string(),
604 }),
605 );
606
607 let tmp_client = RommClient::new(
608 &Config {
609 base_url: "https://dummy.example".to_string(),
610 download_dir: "/tmp".to_string(),
611 use_https: true,
612 auth: None,
613 extras_defaults: crate::config::ExtrasDefaults::default(),
614 save_sync: Default::default(),
615 roms_layout: Default::default(),
616 theme: crate::config::default_theme_id(),
617 tui_layout: Default::default(),
618 },
619 false,
620 )
621 .unwrap();
622
623 let rt = tokio::runtime::Runtime::new().unwrap();
624 rt.block_on(persist_auth_from_login(None, &tmp_client))
625 .unwrap();
626
627 let saved = read_user_config_json_from_disk().unwrap();
628 assert_eq!(saved.base_url, "https://disk.example");
629 assert_eq!(saved.download_dir, "/disk/dl");
630 assert!(saved.use_https);
631 assert!(saved.auth.is_none());
632 }
633}