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::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#[derive(Args, Debug, Clone)]
25pub struct AuthCommand {
26 #[command(subcommand)]
27 pub action: AuthAction,
28}
29
30#[derive(Subcommand, Debug, Clone)]
32pub enum AuthAction {
33 Login(AuthLoginCommand),
35 Logout,
37 Status,
39}
40
41#[derive(Args, Debug, Clone)]
45pub struct AuthLoginCommand {
46 #[arg(long)]
48 pub token: Option<String>,
49
50 #[arg(long)]
52 pub token_file: Option<String>,
53
54 #[arg(long)]
56 pub username: Option<String>,
57
58 #[arg(long)]
60 pub password: Option<String>,
61
62 #[arg(long)]
64 pub password_file: Option<String>,
65
66 #[arg(long)]
68 pub api_key_header: Option<String>,
69
70 #[arg(long)]
72 pub api_key: Option<String>,
73
74 #[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 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 println!("Auth updated: {mode}");
132 }
133
134 let _ = client.verbose();
137 Ok(())
138}
139
140async fn login_interactive(cmd: &AuthLoginCommand, client: &RommClient) -> Result<AuthConfig> {
141 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 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 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
251pub async fn handle(cmd: AuthCommand, client: &RommClient, format: OutputFormat) -> Result<()> {
253 match cmd.action {
254 AuthAction::Login(login) => {
255 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 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 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 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 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}