Skip to main content

slack_rs/auth/
commands.rs

1//! Auth command implementations
2
3use 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
17/// Configuration for login flow
18struct 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
26/// Resolve client ID from CLI args, profile, or prompt
27fn 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
45/// Resolve redirect URI from profile, default, or prompt
46fn 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
64/// Resolve bot scopes from CLI args, profile, or prompt
65fn 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
82/// Resolve user scopes from CLI args, profile, or prompt
83fn 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
100/// Resolve client secret from token store or prompt
101fn 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
123/// Check for missing required parameters in non-interactive mode
124fn 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    // Check client_id
134    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    // Check bot_scopes
143    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    // Check user_scopes
150    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 any parameters are missing, return comprehensive error
157    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
170/// Resolve all login configuration parameters
171fn 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/// Login command with credential prompting - performs OAuth authentication
201///
202/// # Arguments
203/// * `client_id` - Optional OAuth client ID from CLI
204/// * `profile_name` - Optional profile name (defaults to "default")
205/// * `redirect_uri` - OAuth redirect URI (used as fallback if not in profile)
206/// * `_scopes` - OAuth scopes (legacy parameter, unused - use bot_scopes/user_scopes instead)
207/// * `bot_scopes` - Optional bot scopes from CLI
208/// * `user_scopes` - Optional user scopes from CLI
209/// * `base_url` - Optional base URL for testing
210/// * `non_interactive` - Whether running in non-interactive mode
211#[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    // Load existing config to check for saved OAuth settings
226    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    // In non-interactive mode, check all required parameters first
232    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    // Resolve all login configuration parameters
243    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    // Create OAuth config
254    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    // Perform login flow (existing implementation)
263    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 with OAuth config and client_secret to Keyring
267    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, // Legacy field, now stores bot scopes
279        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/// Prompt user for OAuth client ID
290#[allow(dead_code)]
291fn prompt_for_client_id() -> Result<String, OAuthError> {
292    prompt_for_client_id_with_mode(false)
293}
294
295/// Prompt user for OAuth client ID with non-interactive mode support
296fn 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
322/// Prompt user for OAuth client secret (hidden input)
323pub 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            // Add newline after successful password input for better UX
331            println!();
332            return Ok(trimmed.to_string());
333        }
334        eprintln!("Client secret cannot be empty. Please try again.");
335    }
336}
337
338/// Prompt user for OAuth redirect URI with default option
339fn 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
358/// Prompt user for bot OAuth scopes with default "all"
359fn 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
383/// Prompt user for user OAuth scopes with default "all"
384fn 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
408/// Perform OAuth flow and return user/team info and tokens (bot and user)
409async 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    // Validate config
423    config.validate()?;
424
425    // Generate PKCE and state
426    let (code_verifier, code_challenge) = generate_pkce();
427    let state = generate_state();
428
429    // Build authorization URL
430    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    // Try to open browser
438    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    // Start callback server with resolved port
444    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    // Exchange code for token
451    let oauth_response =
452        exchange_code(config, &callback_result.code, &code_verifier, base_url).await?;
453
454    // Extract user and team information
455    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    // Extract bot token (from access_token field)
470    let bot_token = oauth_response.access_token.clone();
471
472    // Extract user token (from authed_user.access_token field)
473    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    // Ensure at least one token is present
493    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
502/// Credentials to save after OAuth authentication
503struct 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>,  // Bot token (optional)
510    user_token: Option<&'a str>, // User token (optional)
511    client_id: &'a str,
512    client_secret: &'a str,
513    redirect_uri: &'a str,
514    scopes: &'a [String],      // Legacy field for backward compatibility
515    bot_scopes: &'a [String],  // New bot scopes field
516    user_scopes: &'a [String], // New user scopes field
517}
518
519/// Save profile and credentials (including client_id and client_secret)
520fn save_profile_and_credentials(creds: SaveCredentials) -> Result<(), OAuthError> {
521    // Load or create config
522    let mut profiles_config =
523        load_config(creds.config_path).unwrap_or_else(|_| ProfilesConfig::new());
524
525    // Create profile with OAuth config (client_id, redirect_uri, bot_scopes, user_scopes)
526    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()), // Legacy field
534        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    // Save tokens to token store
547    let token_store = create_token_store()
548        .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
549
550    // Save bot token to team_id:user_id key (make_token_key format)
551    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    // Save user token to separate key (team_id:user_id:user)
559    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    // Save client_secret to token store
571    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/// Login command - performs OAuth authentication (legacy, delegates to login_with_credentials)
580///
581/// # Arguments
582/// * `config` - OAuth configuration
583/// * `profile_name` - Optional profile name (defaults to "default")
584/// * `base_url` - Optional base URL for testing
585#[allow(dead_code)]
586pub async fn login(
587    config: OAuthConfig,
588    profile_name: Option<String>,
589    base_url: Option<String>,
590) -> Result<(), OAuthError> {
591    // Validate config
592    config.validate()?;
593
594    let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
595
596    // Generate PKCE and state
597    let (code_verifier, code_challenge) = generate_pkce();
598    let state = generate_state();
599
600    // Build authorization URL
601    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    // Try to open browser
609    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    // Start callback server with resolved port
615    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    // Exchange code for token
622    let oauth_response = exchange_code(
623        &config,
624        &callback_result.code,
625        &code_verifier,
626        base_url.as_deref(),
627    )
628    .await?;
629
630    // Extract user and team information
631    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    // Save profile
653    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, // We don't get user name from OAuth response
663        client_id: None, // OAuth client ID not stored in legacy login flow
664        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    // Save token
679    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
692/// Status command - shows current profile status
693///
694/// # Arguments
695/// * `profile_name` - Optional profile name (defaults to "default")
696pub 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    // Check if tokens exist
720    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    // Display available tokens
728    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    // Display Bot ID if bot token exists
743    if has_bot_token {
744        // Extract Bot ID from bot token if available
745        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    // Display scopes
753    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    // Display default token type
765    let default_token_type = if has_user_token { "User" } else { "Bot" };
766    println!("Default Token Type: {}", default_token_type);
767
768    Ok(())
769}
770
771/// Extract Bot ID from a bot token
772/// Bot tokens have format xoxb-{team_id}-{bot_id}-{secret}
773fn extract_bot_id(token: &str) -> Option<String> {
774    if token.starts_with("xoxb-") {
775        let parts: Vec<&str> = token.split('-').collect();
776        // xoxb-{team_id}-{bot_id}-{secret}
777        // parts[0] = "xoxb", parts[1] = team_id, parts[2] = bot_id
778        if parts.len() >= 3 {
779            return Some(parts[2].to_string());
780        }
781    }
782    None
783}
784
785/// List command - lists all profiles
786pub 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
809/// Rename command - renames a profile
810///
811/// # Arguments
812/// * `old_name` - Current profile name
813/// * `new_name` - New profile name
814pub 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    // Check if old profile exists
819    let profile = config
820        .get(&old_name)
821        .ok_or_else(|| format!("Profile '{}' not found", old_name))?
822        .clone();
823
824    // Check if new name already exists
825    if config.get(&new_name).is_some() {
826        return Err(format!("Profile '{}' already exists", new_name));
827    }
828
829    // Remove old profile and add with new name
830    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
840/// Logout command - removes authentication
841///
842/// # Arguments
843/// * `profile_name` - Optional profile name (defaults to "default")
844pub 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    // Delete token
856    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); // Ignore error if token doesn't exist
859
860    // Remove profile
861    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
869/// Try to open a URL in the default browser
870fn 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
889/// Find cloudflared executable in PATH or common locations
890fn find_cloudflared() -> Option<String> {
891    // Try "cloudflared" in PATH first
892    if Command::new("cloudflared")
893        .arg("--version")
894        .output()
895        .is_ok()
896    {
897        return Some("cloudflared".to_string());
898    }
899
900    // Try common installation paths
901    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
916/// Generate and save manifest file for Slack app creation
917fn 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    // Generate manifest YAML
928    let manifest_yaml = generate_manifest(
929        client_id,
930        bot_scopes,
931        user_scopes,
932        redirect_uri,
933        false, // use_cloudflared - not needed for manifest
934        false, // use_ngrok - not needed for manifest
935        profile_name,
936    )
937    .map_err(|e| OAuthError::ConfigError(format!("Failed to generate manifest: {}", e)))?;
938
939    // Determine save path using unified config directory
940    // Use directories::BaseDirs for cross-platform home directory detection
941    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    // Use separate join calls to ensure consistent path separators on Windows
947    let config_dir = home.join(".config").join("slack-rs");
948
949    // Create directory if it doesn't exist
950    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    // Write manifest to file
957    fs::write(&manifest_path, &manifest_yaml)
958        .map_err(|e| OAuthError::ConfigError(format!("Failed to write manifest file: {}", e)))?;
959
960    // Try to copy manifest to clipboard (non-fatal if it fails)
961    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/// Extended login options
981#[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
993/// Extended login with cloudflared tunnel support
994///
995/// This function handles OAuth flow with cloudflared tunnel for public redirect URIs.
996pub 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    // Resolve port early
1016    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        // Check if cloudflared is installed
1023        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    // Generate and save manifest
1077    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    // Verify tunnel is still running
1103    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    // Build OAuth config
1113    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    // Perform OAuth flow (handles browser opening, callback server, token exchange)
1122    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    // Save profile
1145    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    // Cleanup
1167    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        // Test valid bot token format
1187        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        // Test invalid formats
1194        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        // Test various bot token formats
1204        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        // Test with extra dashes in secret (should still work)
1214        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        // This test may fail if there are existing profiles
1223        // It's more of a demonstration of how to use the function
1224        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        // Use a temporary token store file with file backend
1255        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        // Save profile with client_id and client_secret to file store
1260        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        // Verify profile was saved with client_id
1281        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        // Verify tokens were saved to token store (file mode for this test)
1288        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        // Clean up environment variables
1299        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        // Create old-format profile without client_id
1311        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        // Verify it can be loaded
1330        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        // Create token store
1341        let token_store = InMemoryTokenStore::new();
1342
1343        // Test credentials
1344        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        // Simulate what save_profile_and_credentials does
1350        let bot_token_key = make_token_key(team_id, user_id); // team_id:user_id
1351        let user_token_key = format!("{}:{}:user", team_id, user_id); // team_id:user_id:user
1352
1353        token_store.set(&bot_token_key, bot_token).unwrap();
1354        token_store.set(&user_token_key, user_token).unwrap();
1355
1356        // Verify bot token is stored at team_id:user_id
1357        assert_eq!(token_store.get(&bot_token_key).unwrap(), bot_token);
1358        assert_eq!(bot_token_key, "T123:U456");
1359
1360        // Verify user token is stored at team_id:user_id:user
1361        assert_eq!(token_store.get(&user_token_key).unwrap(), user_token);
1362        assert_eq!(user_token_key, "T123:U456:user");
1363
1364        // Verify they are different keys
1365        assert_ne!(bot_token_key, user_token_key);
1366    }
1367}