1use crate::auth::cloudflared::{CloudflaredError, CloudflaredTunnel};
4use crate::debug;
5use crate::oauth::{
6 build_authorization_url, exchange_code, generate_pkce, generate_state, resolve_callback_port,
7 run_callback_server, OAuthConfig, OAuthError,
8};
9use crate::profile::{
10 create_token_store, default_config_path, load_config, make_token_key, save_config, Profile,
11 ProfilesConfig,
12};
13use std::io::{self, Write};
14use std::path::PathBuf;
15use std::process::Command;
16
17struct LoginConfig {
19 client_id: String,
20 client_secret: String,
21 redirect_uri: String,
22 bot_scopes: Vec<String>,
23 user_scopes: Vec<String>,
24}
25
26fn resolve_client_id(
28 cli_arg: Option<String>,
29 existing_profile: Option<&Profile>,
30 non_interactive: bool,
31) -> Result<String, OAuthError> {
32 if let Some(id) = cli_arg {
33 return Ok(id);
34 }
35
36 if let Some(profile) = existing_profile {
37 if let Some(saved_id) = &profile.client_id {
38 return Ok(saved_id.clone());
39 }
40 }
41
42 prompt_for_client_id_with_mode(non_interactive)
43}
44
45fn resolve_redirect_uri(
47 existing_profile: Option<&Profile>,
48 default_uri: &str,
49 non_interactive: bool,
50) -> Result<String, OAuthError> {
51 if let Some(profile) = existing_profile {
52 if let Some(saved_uri) = &profile.redirect_uri {
53 return Ok(saved_uri.clone());
54 }
55 }
56
57 if non_interactive {
58 Ok(default_uri.to_string())
59 } else {
60 prompt_for_redirect_uri(default_uri)
61 }
62}
63
64fn resolve_bot_scopes(
66 cli_arg: Option<Vec<String>>,
67 existing_profile: Option<&Profile>,
68) -> Result<Vec<String>, OAuthError> {
69 if let Some(scopes) = cli_arg {
70 return Ok(scopes);
71 }
72
73 if let Some(profile) = existing_profile {
74 if let Some(saved_scopes) = profile.get_bot_scopes() {
75 return Ok(saved_scopes);
76 }
77 }
78
79 prompt_for_bot_scopes()
80}
81
82fn resolve_user_scopes(
84 cli_arg: Option<Vec<String>>,
85 existing_profile: Option<&Profile>,
86) -> Result<Vec<String>, OAuthError> {
87 if let Some(scopes) = cli_arg {
88 return Ok(scopes);
89 }
90
91 if let Some(profile) = existing_profile {
92 if let Some(saved_scopes) = profile.get_user_scopes() {
93 return Ok(saved_scopes);
94 }
95 }
96
97 prompt_for_user_scopes()
98}
99
100fn resolve_client_secret(
102 token_store: &dyn crate::profile::TokenStore,
103 profile_name: &str,
104 non_interactive: bool,
105) -> Result<String, OAuthError> {
106 match crate::profile::get_oauth_client_secret(token_store, profile_name) {
107 Ok(secret) => {
108 println!("Using saved client secret from token store.");
109 Ok(secret)
110 }
111 Err(_) => {
112 if non_interactive {
113 Err(OAuthError::ConfigError(
114 "Client secret is required. In non-interactive mode, save it first with 'config oauth set'".to_string()
115 ))
116 } else {
117 prompt_for_client_secret()
118 }
119 }
120 }
121}
122
123fn check_non_interactive_params(
125 client_id: &Option<String>,
126 bot_scopes: &Option<Vec<String>>,
127 user_scopes: &Option<Vec<String>>,
128 existing_profile: Option<&Profile>,
129 _profile_name: &str,
130) -> Result<(), OAuthError> {
131 let mut missing_params = Vec::new();
132
133 let has_client_id = client_id.is_some()
135 || existing_profile
136 .and_then(|p| p.client_id.as_ref())
137 .is_some();
138 if !has_client_id {
139 missing_params.push("--client-id <id>");
140 }
141
142 let has_bot_scopes =
144 bot_scopes.is_some() || existing_profile.and_then(|p| p.get_bot_scopes()).is_some();
145 if !has_bot_scopes {
146 missing_params.push("--bot-scopes <scopes>");
147 }
148
149 let has_user_scopes =
151 user_scopes.is_some() || existing_profile.and_then(|p| p.get_user_scopes()).is_some();
152 if !has_user_scopes {
153 missing_params.push("--user-scopes <scopes>");
154 }
155
156 if !missing_params.is_empty() {
158 let missing_list = missing_params.join(", ");
159 return Err(OAuthError::ConfigError(format!(
160 "Missing required OAuth parameters in non-interactive mode: {}\n\
161 Provide them via CLI flags or save with 'config oauth set':\n\
162 Example: slack-rs auth login --client-id <id> --bot-scopes <scopes> --user-scopes <scopes>",
163 missing_list
164 )));
165 }
166
167 Ok(())
168}
169
170fn resolve_login_config(
172 client_id: Option<String>,
173 redirect_uri: &str,
174 bot_scopes: Option<Vec<String>>,
175 user_scopes: Option<Vec<String>>,
176 existing_profile: Option<&Profile>,
177 profile_name: &str,
178 non_interactive: bool,
179) -> Result<LoginConfig, OAuthError> {
180 let token_store = create_token_store()
181 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
182
183 let resolved_client_id = resolve_client_id(client_id, existing_profile, non_interactive)?;
184 let resolved_redirect_uri =
185 resolve_redirect_uri(existing_profile, redirect_uri, non_interactive)?;
186 let resolved_bot_scopes = resolve_bot_scopes(bot_scopes, existing_profile)?;
187 let resolved_user_scopes = resolve_user_scopes(user_scopes, existing_profile)?;
188 let resolved_client_secret =
189 resolve_client_secret(&*token_store, profile_name, non_interactive)?;
190
191 Ok(LoginConfig {
192 client_id: resolved_client_id,
193 client_secret: resolved_client_secret,
194 redirect_uri: resolved_redirect_uri,
195 bot_scopes: resolved_bot_scopes,
196 user_scopes: resolved_user_scopes,
197 })
198}
199
200#[allow(dead_code)]
212#[allow(clippy::too_many_arguments)]
213pub async fn login_with_credentials(
214 client_id: Option<String>,
215 profile_name: Option<String>,
216 redirect_uri: String,
217 _scopes: Vec<String>,
218 bot_scopes: Option<Vec<String>>,
219 user_scopes: Option<Vec<String>>,
220 base_url: Option<String>,
221 non_interactive: bool,
222) -> Result<(), OAuthError> {
223 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
224
225 let config_path = default_config_path()
227 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
228 let existing_config = load_config(&config_path).ok();
229 let existing_profile = existing_config.as_ref().and_then(|c| c.get(&profile_name));
230
231 if non_interactive {
233 check_non_interactive_params(
234 &client_id,
235 &bot_scopes,
236 &user_scopes,
237 existing_profile,
238 &profile_name,
239 )?;
240 }
241
242 let login_config = resolve_login_config(
244 client_id,
245 &redirect_uri,
246 bot_scopes,
247 user_scopes,
248 existing_profile,
249 &profile_name,
250 non_interactive,
251 )?;
252
253 let oauth_config = OAuthConfig {
255 client_id: login_config.client_id.clone(),
256 client_secret: login_config.client_secret.clone(),
257 redirect_uri: login_config.redirect_uri.clone(),
258 scopes: login_config.bot_scopes.clone(),
259 user_scopes: login_config.user_scopes.clone(),
260 };
261
262 let (team_id, team_name, user_id, bot_token, user_token) =
264 perform_oauth_flow(&oauth_config, base_url.as_deref()).await?;
265
266 save_profile_and_credentials(SaveCredentials {
268 config_path: &config_path,
269 profile_name: &profile_name,
270 team_id: &team_id,
271 team_name: &team_name,
272 user_id: &user_id,
273 bot_token: bot_token.as_deref(),
274 user_token: user_token.as_deref(),
275 client_id: &login_config.client_id,
276 client_secret: &login_config.client_secret,
277 redirect_uri: &login_config.redirect_uri,
278 scopes: &login_config.bot_scopes, bot_scopes: &login_config.bot_scopes,
280 user_scopes: &login_config.user_scopes,
281 })?;
282
283 println!("✓ Authentication successful!");
284 println!("Profile '{}' saved.", profile_name);
285
286 Ok(())
287}
288
289#[allow(dead_code)]
291fn prompt_for_client_id() -> Result<String, OAuthError> {
292 prompt_for_client_id_with_mode(false)
293}
294
295fn prompt_for_client_id_with_mode(non_interactive: bool) -> Result<String, OAuthError> {
297 if non_interactive {
298 return Err(OAuthError::ConfigError(
299 "Client ID is required. In non-interactive mode, provide it via --client-id flag or save it in config with 'config oauth set'".to_string()
300 ));
301 }
302
303 loop {
304 print!("Enter OAuth client ID: ");
305 io::stdout()
306 .flush()
307 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
308
309 let mut input = String::new();
310 io::stdin()
311 .read_line(&mut input)
312 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
313
314 let trimmed = input.trim();
315 if !trimmed.is_empty() {
316 return Ok(trimmed.to_string());
317 }
318 eprintln!("Client ID cannot be empty. Please try again.");
319 }
320}
321
322pub fn prompt_for_client_secret() -> Result<String, OAuthError> {
324 loop {
325 let input = rpassword::prompt_password("Enter OAuth client secret: ")
326 .map_err(|e| OAuthError::ConfigError(format!("Failed to read password: {}", e)))?;
327
328 let trimmed = input.trim();
329 if !trimmed.is_empty() {
330 println!();
332 return Ok(trimmed.to_string());
333 }
334 eprintln!("Client secret cannot be empty. Please try again.");
335 }
336}
337
338fn prompt_for_redirect_uri(default: &str) -> Result<String, OAuthError> {
340 print!("Enter OAuth redirect URI [{}]: ", default);
341 io::stdout()
342 .flush()
343 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
344
345 let mut input = String::new();
346 io::stdin()
347 .read_line(&mut input)
348 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
349
350 let trimmed = input.trim();
351 if trimmed.is_empty() {
352 Ok(default.to_string())
353 } else {
354 Ok(trimmed.to_string())
355 }
356}
357
358fn prompt_for_bot_scopes() -> Result<Vec<String>, OAuthError> {
360 print!("Enter bot scopes (comma-separated, or 'all'/'bot:all' for preset) [all]: ");
361 io::stdout()
362 .flush()
363 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
364
365 let mut input = String::new();
366 io::stdin()
367 .read_line(&mut input)
368 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
369
370 let trimmed = input.trim();
371 let scopes_input = if trimmed.is_empty() {
372 vec!["all".to_string()]
373 } else {
374 trimmed.split(',').map(|s| s.trim().to_string()).collect()
375 };
376
377 Ok(crate::oauth::expand_scopes_with_context(
378 &scopes_input,
379 true,
380 ))
381}
382
383fn prompt_for_user_scopes() -> Result<Vec<String>, OAuthError> {
385 print!("Enter user scopes (comma-separated, or 'all'/'user:all' for preset) [all]: ");
386 io::stdout()
387 .flush()
388 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
389
390 let mut input = String::new();
391 io::stdin()
392 .read_line(&mut input)
393 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
394
395 let trimmed = input.trim();
396 let scopes_input = if trimmed.is_empty() {
397 vec!["all".to_string()]
398 } else {
399 trimmed.split(',').map(|s| s.trim().to_string()).collect()
400 };
401
402 Ok(crate::oauth::expand_scopes_with_context(
403 &scopes_input,
404 false,
405 ))
406}
407
408async fn perform_oauth_flow(
410 config: &OAuthConfig,
411 base_url: Option<&str>,
412) -> Result<
413 (
414 String,
415 Option<String>,
416 String,
417 Option<String>,
418 Option<String>,
419 ),
420 OAuthError,
421> {
422 config.validate()?;
424
425 let (code_verifier, code_challenge) = generate_pkce();
427 let state = generate_state();
428
429 let auth_url = build_authorization_url(config, &code_challenge, &state)?;
431
432 println!("Opening browser for authentication...");
433 println!("If the browser doesn't open, visit this URL:");
434 println!("{}", auth_url);
435 println!();
436
437 if let Err(e) = open_browser(&auth_url) {
439 println!("Failed to open browser: {}", e);
440 println!("Please open the URL manually in your browser.");
441 }
442
443 let port = resolve_callback_port()?;
445 println!("Waiting for authentication callback...");
446 let callback_result = run_callback_server(port, state.clone(), 300).await?;
447
448 println!("Received authorization code, exchanging for token...");
449
450 let oauth_response =
452 exchange_code(config, &callback_result.code, &code_verifier, base_url).await?;
453
454 let team_id = oauth_response
456 .team
457 .as_ref()
458 .map(|t| t.id.clone())
459 .ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
460
461 let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
462
463 let user_id = oauth_response
464 .authed_user
465 .as_ref()
466 .map(|u| u.id.clone())
467 .ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
468
469 let bot_token = oauth_response.access_token.clone();
471
472 let user_token = oauth_response
474 .authed_user
475 .as_ref()
476 .and_then(|u| u.access_token.clone());
477
478 if debug::enabled() {
479 debug::log(format!(
480 "OAuth tokens received: bot_token_present={}, user_token_present={}",
481 bot_token.is_some(),
482 user_token.is_some()
483 ));
484 if let Some(ref token) = bot_token {
485 debug::log(format!("bot_token={}", debug::token_hint(token)));
486 }
487 if let Some(ref token) = user_token {
488 debug::log(format!("user_token={}", debug::token_hint(token)));
489 }
490 }
491
492 if bot_token.is_none() && user_token.is_none() {
494 return Err(OAuthError::SlackError(
495 "No access tokens received".to_string(),
496 ));
497 }
498
499 Ok((team_id, team_name, user_id, bot_token, user_token))
500}
501
502struct SaveCredentials<'a> {
504 config_path: &'a std::path::Path,
505 profile_name: &'a str,
506 team_id: &'a str,
507 team_name: &'a Option<String>,
508 user_id: &'a str,
509 bot_token: Option<&'a str>, user_token: Option<&'a str>, client_id: &'a str,
512 client_secret: &'a str,
513 redirect_uri: &'a str,
514 scopes: &'a [String], bot_scopes: &'a [String], user_scopes: &'a [String], }
518
519fn save_profile_and_credentials(creds: SaveCredentials) -> Result<(), OAuthError> {
521 let mut profiles_config =
523 load_config(creds.config_path).unwrap_or_else(|_| ProfilesConfig::new());
524
525 let profile = Profile {
527 team_id: creds.team_id.to_string(),
528 user_id: creds.user_id.to_string(),
529 team_name: creds.team_name.clone(),
530 user_name: None,
531 client_id: Some(creds.client_id.to_string()),
532 redirect_uri: Some(creds.redirect_uri.to_string()),
533 scopes: Some(creds.scopes.to_vec()), bot_scopes: Some(creds.bot_scopes.to_vec()),
535 user_scopes: Some(creds.user_scopes.to_vec()),
536 default_token_type: None,
537 };
538
539 profiles_config
540 .set_or_update(creds.profile_name.to_string(), profile)
541 .map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
542
543 save_config(creds.config_path, &profiles_config)
544 .map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
545
546 let token_store = create_token_store()
548 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
549
550 if let Some(bot_token) = creds.bot_token {
552 let bot_token_key = make_token_key(creds.team_id, creds.user_id);
553 token_store
554 .set(&bot_token_key, bot_token)
555 .map_err(|e| OAuthError::ConfigError(format!("Failed to save bot token: {}", e)))?;
556 }
557
558 if let Some(user_token) = creds.user_token {
560 let user_token_key = format!("{}:{}:user", creds.team_id, creds.user_id);
561 debug::log(format!("Saving user token with key: {}", user_token_key));
562 token_store
563 .set(&user_token_key, user_token)
564 .map_err(|e| OAuthError::ConfigError(format!("Failed to save user token: {}", e)))?;
565 debug::log("User token saved successfully");
566 } else {
567 debug::log("No user token to save (user_token is None)");
568 }
569
570 let client_secret_key = format!("oauth-client-secret:{}", creds.profile_name);
572 token_store
573 .set(&client_secret_key, creds.client_secret)
574 .map_err(|e| OAuthError::ConfigError(format!("Failed to save client secret: {}", e)))?;
575
576 Ok(())
577}
578
579#[allow(dead_code)]
586pub async fn login(
587 config: OAuthConfig,
588 profile_name: Option<String>,
589 base_url: Option<String>,
590) -> Result<(), OAuthError> {
591 config.validate()?;
593
594 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
595
596 let (code_verifier, code_challenge) = generate_pkce();
598 let state = generate_state();
599
600 let auth_url = build_authorization_url(&config, &code_challenge, &state)?;
602
603 println!("Opening browser for authentication...");
604 println!("If the browser doesn't open, visit this URL:");
605 println!("{}", auth_url);
606 println!();
607
608 if let Err(e) = open_browser(&auth_url) {
610 println!("Failed to open browser: {}", e);
611 println!("Please open the URL manually in your browser.");
612 }
613
614 let port = resolve_callback_port()?;
616 println!("Waiting for authentication callback...");
617 let callback_result = run_callback_server(port, state.clone(), 300).await?;
618
619 println!("Received authorization code, exchanging for token...");
620
621 let oauth_response = exchange_code(
623 &config,
624 &callback_result.code,
625 &code_verifier,
626 base_url.as_deref(),
627 )
628 .await?;
629
630 let team_id = oauth_response
632 .team
633 .as_ref()
634 .map(|t| t.id.clone())
635 .ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
636
637 let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
638
639 let user_id = oauth_response
640 .authed_user
641 .as_ref()
642 .map(|u| u.id.clone())
643 .ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
644
645 let token = oauth_response
646 .authed_user
647 .as_ref()
648 .and_then(|u| u.access_token.clone())
649 .or(oauth_response.access_token.clone())
650 .ok_or_else(|| OAuthError::SlackError("Missing access token".to_string()))?;
651
652 let config_path = default_config_path()
654 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
655
656 let mut config = load_config(&config_path).unwrap_or_else(|_| ProfilesConfig::new());
657
658 let profile = Profile {
659 team_id: team_id.clone(),
660 user_id: user_id.clone(),
661 team_name,
662 user_name: None, client_id: None, redirect_uri: None,
665 scopes: None,
666 bot_scopes: None,
667 user_scopes: None,
668 default_token_type: None,
669 };
670
671 config
672 .set_or_update(profile_name.clone(), profile)
673 .map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
674
675 save_config(&config_path, &config)
676 .map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
677
678 let token_store = create_token_store()
680 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
681 let token_key = make_token_key(&team_id, &user_id);
682 token_store
683 .set(&token_key, &token)
684 .map_err(|e| OAuthError::ConfigError(format!("Failed to save token: {}", e)))?;
685
686 println!("✓ Authentication successful!");
687 println!("Profile '{}' saved.", profile_name);
688
689 Ok(())
690}
691
692pub fn status(profile_name: Option<String>) -> Result<(), String> {
697 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
698
699 let config_path = default_config_path().map_err(|e| e.to_string())?;
700 let config = load_config(&config_path).map_err(|e| e.to_string())?;
701
702 let profile = config
703 .get(&profile_name)
704 .ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
705
706 println!("Profile: {}", profile_name);
707 println!("Team ID: {}", profile.team_id);
708 println!("User ID: {}", profile.user_id);
709 if let Some(team_name) = &profile.team_name {
710 println!("Team Name: {}", team_name);
711 }
712 if let Some(user_name) = &profile.user_name {
713 println!("User Name: {}", user_name);
714 }
715 if let Some(client_id) = &profile.client_id {
716 println!("Client ID: {}", client_id);
717 }
718
719 let token_store = create_token_store().map_err(|e| e.to_string())?;
721 let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
722 let user_token_key = format!("{}:{}:user", &profile.team_id, &profile.user_id);
723
724 let has_bot_token = token_store.exists(&bot_token_key);
725 let has_user_token = token_store.exists(&user_token_key);
726
727 let mut available_tokens = Vec::new();
729 if has_bot_token {
730 available_tokens.push("Bot");
731 }
732 if has_user_token {
733 available_tokens.push("User");
734 }
735
736 if available_tokens.is_empty() {
737 println!("Tokens Available: None");
738 } else {
739 println!("Tokens Available: {}", available_tokens.join(", "));
740 }
741
742 if has_bot_token {
744 if let Ok(bot_token) = token_store.get(&bot_token_key) {
746 if let Some(bot_id) = extract_bot_id(&bot_token) {
747 println!("Bot ID: {}", bot_id);
748 }
749 }
750 }
751
752 if let Some(bot_scopes) = profile.get_bot_scopes() {
754 if !bot_scopes.is_empty() {
755 println!("Bot Scopes: {}", bot_scopes.join(", "));
756 }
757 }
758 if let Some(user_scopes) = profile.get_user_scopes() {
759 if !user_scopes.is_empty() {
760 println!("User Scopes: {}", user_scopes.join(", "));
761 }
762 }
763
764 let default_token_type = if has_user_token { "User" } else { "Bot" };
766 println!("Default Token Type: {}", default_token_type);
767
768 Ok(())
769}
770
771fn extract_bot_id(token: &str) -> Option<String> {
774 if token.starts_with("xoxb-") {
775 let parts: Vec<&str> = token.split('-').collect();
776 if parts.len() >= 3 {
779 return Some(parts[2].to_string());
780 }
781 }
782 None
783}
784
785pub fn list() -> Result<(), String> {
787 let config_path = default_config_path().map_err(|e| e.to_string())?;
788 let config = load_config(&config_path).map_err(|e| e.to_string())?;
789
790 if config.profiles.is_empty() {
791 println!("No profiles found.");
792 return Ok(());
793 }
794
795 println!("Profiles:");
796 for name in config.list_names() {
797 if let Some(profile) = config.get(&name) {
798 let team_name = profile.team_name.as_deref().unwrap_or(&profile.team_id);
799 println!(
800 " {}: {} ({}:{})",
801 name, team_name, profile.team_id, profile.user_id
802 );
803 }
804 }
805
806 Ok(())
807}
808
809pub fn rename(old_name: String, new_name: String) -> Result<(), String> {
815 let config_path = default_config_path().map_err(|e| e.to_string())?;
816 let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
817
818 let profile = config
820 .get(&old_name)
821 .ok_or_else(|| format!("Profile '{}' not found", old_name))?
822 .clone();
823
824 if config.get(&new_name).is_some() {
826 return Err(format!("Profile '{}' already exists", new_name));
827 }
828
829 config.remove(&old_name);
831 config.set(new_name.clone(), profile);
832
833 save_config(&config_path, &config).map_err(|e| e.to_string())?;
834
835 println!("Profile '{}' renamed to '{}'", old_name, new_name);
836
837 Ok(())
838}
839
840pub fn logout(profile_name: Option<String>) -> Result<(), String> {
845 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
846
847 let config_path = default_config_path().map_err(|e| e.to_string())?;
848 let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
849
850 let profile = config
851 .get(&profile_name)
852 .ok_or_else(|| format!("Profile '{}' not found", profile_name))?
853 .clone();
854
855 let token_store = create_token_store().map_err(|e| e.to_string())?;
857 let token_key = make_token_key(&profile.team_id, &profile.user_id);
858 let _ = token_store.delete(&token_key); config.remove(&profile_name);
862 save_config(&config_path, &config).map_err(|e| e.to_string())?;
863
864 println!("Profile '{}' removed", profile_name);
865
866 Ok(())
867}
868
869fn open_browser(url: &str) -> Result<(), String> {
871 #[cfg(target_os = "macos")]
872 let result = Command::new("open").arg(url).spawn();
873
874 #[cfg(target_os = "linux")]
875 let result = Command::new("xdg-open").arg(url).spawn();
876
877 #[cfg(target_os = "windows")]
878 let result = Command::new("cmd").args(["/C", "start", url]).spawn();
879
880 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
881 let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
882 std::io::ErrorKind::Unsupported,
883 "Unsupported platform",
884 ));
885
886 result.map(|_| ()).map_err(|e| e.to_string())
887}
888
889fn find_cloudflared() -> Option<String> {
891 if Command::new("cloudflared")
893 .arg("--version")
894 .output()
895 .is_ok()
896 {
897 return Some("cloudflared".to_string());
898 }
899
900 let common_paths = [
902 "/usr/local/bin/cloudflared",
903 "/opt/homebrew/bin/cloudflared",
904 "/usr/bin/cloudflared",
905 ];
906
907 for path in &common_paths {
908 if std::path::Path::new(path).exists() {
909 return Some(path.to_string());
910 }
911 }
912
913 None
914}
915
916fn generate_and_save_manifest(
918 client_id: &str,
919 redirect_uri: &str,
920 bot_scopes: &[String],
921 user_scopes: &[String],
922 profile_name: &str,
923) -> Result<PathBuf, OAuthError> {
924 use crate::auth::manifest::generate_manifest;
925 use std::fs;
926
927 let manifest_yaml = generate_manifest(
929 client_id,
930 bot_scopes,
931 user_scopes,
932 redirect_uri,
933 false, false, profile_name,
936 )
937 .map_err(|e| OAuthError::ConfigError(format!("Failed to generate manifest: {}", e)))?;
938
939 let home = directories::BaseDirs::new()
942 .ok_or_else(|| OAuthError::ConfigError("Failed to determine home directory".to_string()))?
943 .home_dir()
944 .to_path_buf();
945
946 let config_dir = home.join(".config").join("slack-rs");
948
949 fs::create_dir_all(&config_dir).map_err(|e| {
951 OAuthError::ConfigError(format!("Failed to create config directory: {}", e))
952 })?;
953
954 let manifest_path = config_dir.join(format!("{}_manifest.yml", profile_name));
955
956 fs::write(&manifest_path, &manifest_yaml)
958 .map_err(|e| OAuthError::ConfigError(format!("Failed to write manifest file: {}", e)))?;
959
960 match arboard::Clipboard::new() {
962 Ok(mut clipboard) => match clipboard.set_text(&manifest_yaml) {
963 Ok(_) => {
964 println!("✓ Manifest copied to clipboard!");
965 }
966 Err(e) => {
967 eprintln!("⚠️ Warning: Failed to copy manifest to clipboard: {}", e);
968 eprintln!(" You can still manually copy from the file.");
969 }
970 },
971 Err(e) => {
972 eprintln!("⚠️ Warning: Failed to access clipboard: {}", e);
973 eprintln!(" You can still manually copy from the file.");
974 }
975 }
976
977 Ok(manifest_path)
978}
979
980#[allow(dead_code)]
982pub struct ExtendedLoginOptions {
983 pub client_id: Option<String>,
984 pub profile_name: Option<String>,
985 pub redirect_uri: String,
986 pub bot_scopes: Option<Vec<String>>,
987 pub user_scopes: Option<Vec<String>>,
988 pub cloudflared_path: Option<String>,
989 pub ngrok_path: Option<String>,
990 pub base_url: Option<String>,
991}
992
993pub async fn login_with_credentials_extended(
997 client_id: String,
998 client_secret: String,
999 bot_scopes: Vec<String>,
1000 user_scopes: Vec<String>,
1001 profile_name: Option<String>,
1002 use_cloudflared: bool,
1003) -> Result<(), OAuthError> {
1004 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
1005
1006 if debug::enabled() {
1007 debug::log(format!(
1008 "login_with_credentials_extended: profile={}, bot_scopes_count={}, user_scopes_count={}",
1009 profile_name,
1010 bot_scopes.len(),
1011 user_scopes.len()
1012 ));
1013 }
1014
1015 let port = resolve_callback_port()?;
1017
1018 let final_redirect_uri: String;
1019 let mut cloudflared_tunnel: Option<CloudflaredTunnel> = None;
1020
1021 if use_cloudflared {
1022 let path = match find_cloudflared() {
1024 Some(p) => p,
1025 None => {
1026 return Err(OAuthError::ConfigError(
1027 "cloudflared not found. Please install it first:\n \
1028 macOS: brew install cloudflare/cloudflare/cloudflared\n \
1029 Linux: See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
1030 .to_string(),
1031 ));
1032 }
1033 };
1034
1035 println!("Starting cloudflared tunnel...");
1036 let local_url = format!("http://localhost:{}", port);
1037 match CloudflaredTunnel::start(&path, &local_url, 30) {
1038 Ok(mut t) => {
1039 let public_url = t.public_url().to_string();
1040 println!("✓ Tunnel started: {}", public_url);
1041 println!(" Tunneling {} -> {}", public_url, local_url);
1042
1043 if !t.is_running() {
1044 return Err(OAuthError::ConfigError(
1045 "Cloudflared tunnel started but process is not running".to_string(),
1046 ));
1047 }
1048
1049 final_redirect_uri = format!("{}/callback", public_url);
1050 println!("Using redirect URI: {}", final_redirect_uri);
1051 cloudflared_tunnel = Some(t);
1052 }
1053 Err(CloudflaredError::StartError(msg)) => {
1054 return Err(OAuthError::ConfigError(format!(
1055 "Failed to start cloudflared: {}",
1056 msg
1057 )));
1058 }
1059 Err(CloudflaredError::UrlExtractionError(msg)) => {
1060 return Err(OAuthError::ConfigError(format!(
1061 "Failed to extract cloudflared URL: {}",
1062 msg
1063 )));
1064 }
1065 Err(e) => {
1066 return Err(OAuthError::ConfigError(format!(
1067 "Cloudflared error: {:?}",
1068 e
1069 )));
1070 }
1071 }
1072 } else {
1073 final_redirect_uri = format!("http://localhost:{}/callback", port);
1074 }
1075
1076 let manifest_path = generate_and_save_manifest(
1078 &client_id,
1079 &final_redirect_uri,
1080 &bot_scopes,
1081 &user_scopes,
1082 &profile_name,
1083 )?;
1084
1085 println!("\n📋 Slack App Manifest saved to:");
1086 println!(" {}", manifest_path.display());
1087 println!("\n🔧 Setup Instructions:");
1088 println!(" 1. Go to https://api.slack.com/apps");
1089 println!(" 2. Click 'Create New App' → 'From an app manifest'");
1090 println!(" 3. Select your workspace");
1091 println!(" 4. Copy and paste the manifest from the file above");
1092 println!(" 5. Click 'Create'");
1093 println!(" 6. ⚠️ IMPORTANT: Do NOT click 'Install to Workspace' yet!");
1094 println!(" The OAuth flow will start automatically after you press Enter.");
1095 println!("\n⏸️ Press Enter when you've created the app (but NOT installed it yet)...");
1096
1097 let mut input = String::new();
1098 std::io::stdin()
1099 .read_line(&mut input)
1100 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
1101
1102 if let Some(ref mut tunnel) = cloudflared_tunnel {
1104 if !tunnel.is_running() {
1105 return Err(OAuthError::ConfigError(
1106 "Cloudflared tunnel stopped unexpectedly".to_string(),
1107 ));
1108 }
1109 println!("✓ Tunnel is running");
1110 }
1111
1112 let config = OAuthConfig {
1114 client_id: client_id.clone(),
1115 client_secret: client_secret.clone(),
1116 redirect_uri: final_redirect_uri.clone(),
1117 scopes: bot_scopes.clone(),
1118 user_scopes: user_scopes.clone(),
1119 };
1120
1121 println!("🔄 Starting OAuth flow...");
1123 let (team_id, team_name, user_id, bot_token, user_token) =
1124 perform_oauth_flow(&config, None).await?;
1125
1126 if debug::enabled() {
1127 debug::log(format!(
1128 "OAuth flow completed: team_id={}, user_id={}, team_name={:?}",
1129 team_id, user_id, team_name
1130 ));
1131 debug::log(format!(
1132 "tokens: bot_token_present={}, user_token_present={}",
1133 bot_token.is_some(),
1134 user_token.is_some()
1135 ));
1136 if let Some(ref token) = bot_token {
1137 debug::log(format!("bot_token={}", debug::token_hint(token)));
1138 }
1139 if let Some(ref token) = user_token {
1140 debug::log(format!("user_token={}", debug::token_hint(token)));
1141 }
1142 }
1143
1144 println!("💾 Saving profile and credentials...");
1146 save_profile_and_credentials(SaveCredentials {
1147 config_path: &default_config_path()
1148 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?,
1149 profile_name: &profile_name,
1150 team_id: &team_id,
1151 team_name: &team_name,
1152 user_id: &user_id,
1153 bot_token: bot_token.as_deref(),
1154 user_token: user_token.as_deref(),
1155 client_id: &client_id,
1156 client_secret: &client_secret,
1157 redirect_uri: &final_redirect_uri,
1158 scopes: &bot_scopes,
1159 bot_scopes: &bot_scopes,
1160 user_scopes: &user_scopes,
1161 })?;
1162
1163 println!("\n✅ Login successful!");
1164 println!("Profile '{}' has been saved.", profile_name);
1165
1166 drop(cloudflared_tunnel);
1168
1169 Ok(())
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use crate::profile::TokenStore;
1176
1177 #[test]
1178 fn test_status_profile_not_found() {
1179 let result = status(Some("nonexistent".to_string()));
1180 assert!(result.is_err());
1181 assert!(result.unwrap_err().contains("not found"));
1182 }
1183
1184 #[test]
1185 fn test_extract_bot_id_valid() {
1186 let token = "xoxb-T123-B456-secret123";
1188 assert_eq!(extract_bot_id(token), Some("B456".to_string()));
1189 }
1190
1191 #[test]
1192 fn test_extract_bot_id_invalid() {
1193 assert_eq!(extract_bot_id("xoxp-user-token"), None);
1195 assert_eq!(extract_bot_id("xoxb-only"), None);
1196 assert_eq!(extract_bot_id("xoxb-T123"), None);
1197 assert_eq!(extract_bot_id("not-a-token"), None);
1198 assert_eq!(extract_bot_id(""), None);
1199 }
1200
1201 #[test]
1202 fn test_extract_bot_id_edge_cases() {
1203 assert_eq!(
1205 extract_bot_id("xoxb-123456-789012-abcdef"),
1206 Some("789012".to_string())
1207 );
1208 assert_eq!(
1209 extract_bot_id("xoxb-T123-B456-secret123"),
1210 Some("B456".to_string())
1211 );
1212
1213 assert_eq!(
1215 extract_bot_id("xoxb-T123-B456-secret-with-dashes"),
1216 Some("B456".to_string())
1217 );
1218 }
1219
1220 #[test]
1221 fn test_list_empty() {
1222 let result = list();
1225 assert!(result.is_ok());
1226 }
1227
1228 #[test]
1229 fn test_rename_nonexistent_profile() {
1230 let result = rename("nonexistent".to_string(), "new_name".to_string());
1231 assert!(result.is_err());
1232 assert!(result.unwrap_err().contains("not found"));
1233 }
1234
1235 #[test]
1236 fn test_logout_nonexistent_profile() {
1237 let result = logout(Some("nonexistent".to_string()));
1238 assert!(result.is_err());
1239 assert!(result.unwrap_err().contains("not found"));
1240 }
1241
1242 #[test]
1243 #[serial_test::serial]
1244 fn test_save_profile_and_credentials_with_client_id() {
1245 use tempfile::TempDir;
1246
1247 let temp_dir = TempDir::new().unwrap();
1248 let config_path = temp_dir.path().join("profiles.json");
1249
1250 let team_id = "T123";
1251 let user_id = "U456";
1252 let profile_name = "test";
1253
1254 let tokens_path = temp_dir.path().join("tokens.json");
1256 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1257 std::env::set_var("SLACKRS_TOKEN_STORE", "file");
1258
1259 let scopes = vec!["chat:write".to_string(), "users:read".to_string()];
1261 let bot_scopes = vec!["chat:write".to_string()];
1262 let user_scopes = vec!["users:read".to_string()];
1263 save_profile_and_credentials(SaveCredentials {
1264 config_path: &config_path,
1265 profile_name,
1266 team_id,
1267 team_name: &Some("Test Team".to_string()),
1268 user_id,
1269 bot_token: Some("xoxb-test-bot-token"),
1270 user_token: Some("xoxp-test-user-token"),
1271 client_id: "test-client-id",
1272 client_secret: "test-client-secret",
1273 redirect_uri: "http://127.0.0.1:8765/callback",
1274 scopes: &scopes,
1275 bot_scopes: &bot_scopes,
1276 user_scopes: &user_scopes,
1277 })
1278 .unwrap();
1279
1280 let config = load_config(&config_path).unwrap();
1282 let profile = config.get(profile_name).unwrap();
1283 assert_eq!(profile.client_id, Some("test-client-id".to_string()));
1284 assert_eq!(profile.team_id, team_id);
1285 assert_eq!(profile.user_id, user_id);
1286
1287 use crate::profile::FileTokenStore;
1289 let token_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
1290 let bot_token_key = make_token_key(team_id, user_id);
1291 let user_token_key = format!("{}:{}:user", team_id, user_id);
1292 let client_secret_key = format!("oauth-client-secret:{}", profile_name);
1293
1294 assert!(token_store.exists(&bot_token_key));
1295 assert!(token_store.exists(&user_token_key));
1296 assert!(token_store.exists(&client_secret_key));
1297
1298 std::env::remove_var("SLACKRS_TOKEN_STORE");
1300 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1301 }
1302
1303 #[test]
1304 fn test_backward_compatibility_load_profile_without_client_id() {
1305 use tempfile::TempDir;
1306
1307 let temp_dir = TempDir::new().unwrap();
1308 let config_path = temp_dir.path().join("profiles.json");
1309
1310 let mut config = ProfilesConfig::new();
1312 config.set(
1313 "legacy".to_string(),
1314 Profile {
1315 team_id: "T999".to_string(),
1316 user_id: "U888".to_string(),
1317 team_name: Some("Legacy Team".to_string()),
1318 user_name: Some("Legacy User".to_string()),
1319 client_id: None,
1320 redirect_uri: None,
1321 scopes: None,
1322 bot_scopes: None,
1323 user_scopes: None,
1324 default_token_type: None,
1325 },
1326 );
1327 save_config(&config_path, &config).unwrap();
1328
1329 let loaded_config = load_config(&config_path).unwrap();
1331 let profile = loaded_config.get("legacy").unwrap();
1332 assert_eq!(profile.client_id, None);
1333 assert_eq!(profile.team_id, "T999");
1334 }
1335
1336 #[test]
1337 fn test_bot_and_user_token_storage_keys() {
1338 use crate::profile::InMemoryTokenStore;
1339
1340 let token_store = InMemoryTokenStore::new();
1342
1343 let team_id = "T123";
1345 let user_id = "U456";
1346 let bot_token = "xoxb-test-bot-token";
1347 let user_token = "xoxp-test-user-token";
1348
1349 let bot_token_key = make_token_key(team_id, user_id); let user_token_key = format!("{}:{}:user", team_id, user_id); token_store.set(&bot_token_key, bot_token).unwrap();
1354 token_store.set(&user_token_key, user_token).unwrap();
1355
1356 assert_eq!(token_store.get(&bot_token_key).unwrap(), bot_token);
1358 assert_eq!(bot_token_key, "T123:U456");
1359
1360 assert_eq!(token_store.get(&user_token_key).unwrap(), user_token);
1362 assert_eq!(user_token_key, "T123:U456:user");
1363
1364 assert_ne!(bot_token_key, user_token_key);
1366 }
1367}