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
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 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 println!("Auth updated: {mode}");
141 }
142
143 let _ = client.verbose();
146 Ok(())
147}
148
149async fn login_interactive(cmd: &AuthLoginCommand, client: &RommClient) -> Result<AuthConfig> {
150 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 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 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
265pub async fn handle(cmd: AuthCommand, client: &RommClient, format: OutputFormat) -> Result<()> {
267 match cmd.action {
268 AuthAction::Login(login) => {
269 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 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 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 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 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}