use crate::auth::cloudflared::{CloudflaredError, CloudflaredTunnel};
use crate::debug;
use crate::oauth::{
build_authorization_url, exchange_code, generate_pkce, generate_state, resolve_callback_port,
run_callback_server, OAuthConfig, OAuthError,
};
use crate::profile::{
create_token_store, default_config_path, load_config, make_token_key, save_config, Profile,
ProfilesConfig,
};
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::Command;
struct LoginConfig {
client_id: String,
client_secret: String,
redirect_uri: String,
bot_scopes: Vec<String>,
user_scopes: Vec<String>,
}
fn resolve_client_id(
cli_arg: Option<String>,
existing_profile: Option<&Profile>,
non_interactive: bool,
) -> Result<String, OAuthError> {
if let Some(id) = cli_arg {
return Ok(id);
}
if let Some(profile) = existing_profile {
if let Some(saved_id) = &profile.client_id {
return Ok(saved_id.clone());
}
}
prompt_for_client_id_with_mode(non_interactive)
}
fn resolve_redirect_uri(
existing_profile: Option<&Profile>,
default_uri: &str,
non_interactive: bool,
) -> Result<String, OAuthError> {
if let Some(profile) = existing_profile {
if let Some(saved_uri) = &profile.redirect_uri {
return Ok(saved_uri.clone());
}
}
if non_interactive {
Ok(default_uri.to_string())
} else {
prompt_for_redirect_uri(default_uri)
}
}
fn resolve_bot_scopes(
cli_arg: Option<Vec<String>>,
existing_profile: Option<&Profile>,
) -> Result<Vec<String>, OAuthError> {
if let Some(scopes) = cli_arg {
return Ok(scopes);
}
if let Some(profile) = existing_profile {
if let Some(saved_scopes) = profile.get_bot_scopes() {
return Ok(saved_scopes);
}
}
prompt_for_bot_scopes()
}
fn resolve_user_scopes(
cli_arg: Option<Vec<String>>,
existing_profile: Option<&Profile>,
) -> Result<Vec<String>, OAuthError> {
if let Some(scopes) = cli_arg {
return Ok(scopes);
}
if let Some(profile) = existing_profile {
if let Some(saved_scopes) = profile.get_user_scopes() {
return Ok(saved_scopes);
}
}
prompt_for_user_scopes()
}
fn resolve_client_secret(
token_store: &dyn crate::profile::TokenStore,
profile_name: &str,
non_interactive: bool,
) -> Result<String, OAuthError> {
match crate::profile::get_oauth_client_secret(token_store, profile_name) {
Ok(secret) => {
println!("Using saved client secret from token store.");
Ok(secret)
}
Err(_) => {
if non_interactive {
Err(OAuthError::ConfigError(
"Client secret is required. In non-interactive mode, save it first with 'config oauth set'".to_string()
))
} else {
prompt_for_client_secret()
}
}
}
}
fn check_non_interactive_params(
client_id: &Option<String>,
bot_scopes: &Option<Vec<String>>,
user_scopes: &Option<Vec<String>>,
existing_profile: Option<&Profile>,
_profile_name: &str,
) -> Result<(), OAuthError> {
let mut missing_params = Vec::new();
let has_client_id = client_id.is_some()
|| existing_profile
.and_then(|p| p.client_id.as_ref())
.is_some();
if !has_client_id {
missing_params.push("--client-id <id>");
}
let has_bot_scopes =
bot_scopes.is_some() || existing_profile.and_then(|p| p.get_bot_scopes()).is_some();
if !has_bot_scopes {
missing_params.push("--bot-scopes <scopes>");
}
let has_user_scopes =
user_scopes.is_some() || existing_profile.and_then(|p| p.get_user_scopes()).is_some();
if !has_user_scopes {
missing_params.push("--user-scopes <scopes>");
}
if !missing_params.is_empty() {
let missing_list = missing_params.join(", ");
return Err(OAuthError::ConfigError(format!(
"Missing required OAuth parameters in non-interactive mode: {}\n\
Provide them via CLI flags or save with 'config oauth set':\n\
Example: slack-rs auth login --client-id <id> --bot-scopes <scopes> --user-scopes <scopes>",
missing_list
)));
}
Ok(())
}
fn resolve_login_config(
client_id: Option<String>,
redirect_uri: &str,
bot_scopes: Option<Vec<String>>,
user_scopes: Option<Vec<String>>,
existing_profile: Option<&Profile>,
profile_name: &str,
non_interactive: bool,
) -> Result<LoginConfig, OAuthError> {
let token_store = create_token_store()
.map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
let resolved_client_id = resolve_client_id(client_id, existing_profile, non_interactive)?;
let resolved_redirect_uri =
resolve_redirect_uri(existing_profile, redirect_uri, non_interactive)?;
let resolved_bot_scopes = resolve_bot_scopes(bot_scopes, existing_profile)?;
let resolved_user_scopes = resolve_user_scopes(user_scopes, existing_profile)?;
let resolved_client_secret =
resolve_client_secret(&*token_store, profile_name, non_interactive)?;
Ok(LoginConfig {
client_id: resolved_client_id,
client_secret: resolved_client_secret,
redirect_uri: resolved_redirect_uri,
bot_scopes: resolved_bot_scopes,
user_scopes: resolved_user_scopes,
})
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub async fn login_with_credentials(
client_id: Option<String>,
profile_name: Option<String>,
redirect_uri: String,
_scopes: Vec<String>,
bot_scopes: Option<Vec<String>>,
user_scopes: Option<Vec<String>>,
base_url: Option<String>,
non_interactive: bool,
) -> Result<(), OAuthError> {
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
let config_path = default_config_path()
.map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
let existing_config = load_config(&config_path).ok();
let existing_profile = existing_config.as_ref().and_then(|c| c.get(&profile_name));
if non_interactive {
check_non_interactive_params(
&client_id,
&bot_scopes,
&user_scopes,
existing_profile,
&profile_name,
)?;
}
let login_config = resolve_login_config(
client_id,
&redirect_uri,
bot_scopes,
user_scopes,
existing_profile,
&profile_name,
non_interactive,
)?;
let oauth_config = OAuthConfig {
client_id: login_config.client_id.clone(),
client_secret: login_config.client_secret.clone(),
redirect_uri: login_config.redirect_uri.clone(),
scopes: login_config.bot_scopes.clone(),
user_scopes: login_config.user_scopes.clone(),
};
let (team_id, team_name, user_id, bot_token, user_token) =
perform_oauth_flow(&oauth_config, base_url.as_deref()).await?;
save_profile_and_credentials(SaveCredentials {
config_path: &config_path,
profile_name: &profile_name,
team_id: &team_id,
team_name: &team_name,
user_id: &user_id,
bot_token: bot_token.as_deref(),
user_token: user_token.as_deref(),
client_id: &login_config.client_id,
client_secret: &login_config.client_secret,
redirect_uri: &login_config.redirect_uri,
scopes: &login_config.bot_scopes, bot_scopes: &login_config.bot_scopes,
user_scopes: &login_config.user_scopes,
})?;
println!("✓ Authentication successful!");
println!("Profile '{}' saved.", profile_name);
Ok(())
}
#[allow(dead_code)]
fn prompt_for_client_id() -> Result<String, OAuthError> {
prompt_for_client_id_with_mode(false)
}
fn prompt_for_client_id_with_mode(non_interactive: bool) -> Result<String, OAuthError> {
if non_interactive {
return Err(OAuthError::ConfigError(
"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()
));
}
loop {
print!("Enter OAuth client ID: ");
io::stdout()
.flush()
.map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
let trimmed = input.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
eprintln!("Client ID cannot be empty. Please try again.");
}
}
fn prompt_for_client_secret() -> Result<String, OAuthError> {
loop {
let input = rpassword::prompt_password("Enter OAuth client secret: ")
.map_err(|e| OAuthError::ConfigError(format!("Failed to read password: {}", e)))?;
let trimmed = input.trim();
if !trimmed.is_empty() {
println!();
return Ok(trimmed.to_string());
}
eprintln!("Client secret cannot be empty. Please try again.");
}
}
fn prompt_for_redirect_uri(default: &str) -> Result<String, OAuthError> {
print!("Enter OAuth redirect URI [{}]: ", default);
io::stdout()
.flush()
.map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(default.to_string())
} else {
Ok(trimmed.to_string())
}
}
fn prompt_for_bot_scopes() -> Result<Vec<String>, OAuthError> {
print!("Enter bot scopes (comma-separated, or 'all'/'bot:all' for preset) [all]: ");
io::stdout()
.flush()
.map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
let trimmed = input.trim();
let scopes_input = if trimmed.is_empty() {
vec!["all".to_string()]
} else {
trimmed.split(',').map(|s| s.trim().to_string()).collect()
};
Ok(crate::oauth::expand_scopes_with_context(
&scopes_input,
true,
))
}
fn prompt_for_user_scopes() -> Result<Vec<String>, OAuthError> {
print!("Enter user scopes (comma-separated, or 'all'/'user:all' for preset) [all]: ");
io::stdout()
.flush()
.map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
let trimmed = input.trim();
let scopes_input = if trimmed.is_empty() {
vec!["all".to_string()]
} else {
trimmed.split(',').map(|s| s.trim().to_string()).collect()
};
Ok(crate::oauth::expand_scopes_with_context(
&scopes_input,
false,
))
}
async fn perform_oauth_flow(
config: &OAuthConfig,
base_url: Option<&str>,
) -> Result<
(
String,
Option<String>,
String,
Option<String>,
Option<String>,
),
OAuthError,
> {
config.validate()?;
let (code_verifier, code_challenge) = generate_pkce();
let state = generate_state();
let auth_url = build_authorization_url(config, &code_challenge, &state)?;
println!("Opening browser for authentication...");
println!("If the browser doesn't open, visit this URL:");
println!("{}", auth_url);
println!();
if let Err(e) = open_browser(&auth_url) {
println!("Failed to open browser: {}", e);
println!("Please open the URL manually in your browser.");
}
let port = resolve_callback_port()?;
println!("Waiting for authentication callback...");
let callback_result = run_callback_server(port, state.clone(), 300).await?;
println!("Received authorization code, exchanging for token...");
let oauth_response =
exchange_code(config, &callback_result.code, &code_verifier, base_url).await?;
let team_id = oauth_response
.team
.as_ref()
.map(|t| t.id.clone())
.ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
let user_id = oauth_response
.authed_user
.as_ref()
.map(|u| u.id.clone())
.ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
let bot_token = oauth_response.access_token.clone();
let user_token = oauth_response
.authed_user
.as_ref()
.and_then(|u| u.access_token.clone());
if debug::enabled() {
debug::log(format!(
"OAuth tokens received: bot_token_present={}, user_token_present={}",
bot_token.is_some(),
user_token.is_some()
));
if let Some(ref token) = bot_token {
debug::log(format!("bot_token={}", debug::token_hint(token)));
}
if let Some(ref token) = user_token {
debug::log(format!("user_token={}", debug::token_hint(token)));
}
}
if bot_token.is_none() && user_token.is_none() {
return Err(OAuthError::SlackError(
"No access tokens received".to_string(),
));
}
Ok((team_id, team_name, user_id, bot_token, user_token))
}
struct SaveCredentials<'a> {
config_path: &'a std::path::Path,
profile_name: &'a str,
team_id: &'a str,
team_name: &'a Option<String>,
user_id: &'a str,
bot_token: Option<&'a str>, user_token: Option<&'a str>, client_id: &'a str,
client_secret: &'a str,
redirect_uri: &'a str,
scopes: &'a [String], bot_scopes: &'a [String], user_scopes: &'a [String], }
fn save_profile_and_credentials(creds: SaveCredentials) -> Result<(), OAuthError> {
let mut profiles_config =
load_config(creds.config_path).unwrap_or_else(|_| ProfilesConfig::new());
let existing_default_token_type = profiles_config
.get(creds.profile_name)
.and_then(|p| p.default_token_type);
let has_user_token = creds.user_token.is_some();
let default_token_type =
compute_initial_default_token_type(existing_default_token_type, has_user_token);
let profile = Profile {
team_id: creds.team_id.to_string(),
user_id: creds.user_id.to_string(),
team_name: creds.team_name.clone(),
user_name: None,
client_id: Some(creds.client_id.to_string()),
redirect_uri: Some(creds.redirect_uri.to_string()),
scopes: Some(creds.scopes.to_vec()), bot_scopes: Some(creds.bot_scopes.to_vec()),
user_scopes: Some(creds.user_scopes.to_vec()),
default_token_type: Some(default_token_type),
};
profiles_config
.set_or_update(creds.profile_name.to_string(), profile)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
save_config(creds.config_path, &profiles_config)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
let token_store = create_token_store()
.map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
if let Some(bot_token) = creds.bot_token {
let bot_token_key = make_token_key(creds.team_id, creds.user_id);
token_store
.set(&bot_token_key, bot_token)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save bot token: {}", e)))?;
}
if let Some(user_token) = creds.user_token {
let user_token_key = format!("{}:{}:user", creds.team_id, creds.user_id);
debug::log(format!("Saving user token with key: {}", user_token_key));
token_store
.set(&user_token_key, user_token)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save user token: {}", e)))?;
debug::log("User token saved successfully");
} else {
debug::log("No user token to save (user_token is None)");
}
let client_secret_key = format!("oauth-client-secret:{}", creds.profile_name);
token_store
.set(&client_secret_key, creds.client_secret)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save client secret: {}", e)))?;
Ok(())
}
#[allow(dead_code)]
pub async fn login(
config: OAuthConfig,
profile_name: Option<String>,
base_url: Option<String>,
) -> Result<(), OAuthError> {
config.validate()?;
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
let (code_verifier, code_challenge) = generate_pkce();
let state = generate_state();
let auth_url = build_authorization_url(&config, &code_challenge, &state)?;
println!("Opening browser for authentication...");
println!("If the browser doesn't open, visit this URL:");
println!("{}", auth_url);
println!();
if let Err(e) = open_browser(&auth_url) {
println!("Failed to open browser: {}", e);
println!("Please open the URL manually in your browser.");
}
let port = resolve_callback_port()?;
println!("Waiting for authentication callback...");
let callback_result = run_callback_server(port, state.clone(), 300).await?;
println!("Received authorization code, exchanging for token...");
let oauth_response = exchange_code(
&config,
&callback_result.code,
&code_verifier,
base_url.as_deref(),
)
.await?;
let team_id = oauth_response
.team
.as_ref()
.map(|t| t.id.clone())
.ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
let user_id = oauth_response
.authed_user
.as_ref()
.map(|u| u.id.clone())
.ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
let token = oauth_response
.authed_user
.as_ref()
.and_then(|u| u.access_token.clone())
.or(oauth_response.access_token.clone())
.ok_or_else(|| OAuthError::SlackError("Missing access token".to_string()))?;
let config_path = default_config_path()
.map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
let mut config = load_config(&config_path).unwrap_or_else(|_| ProfilesConfig::new());
let profile = Profile {
team_id: team_id.clone(),
user_id: user_id.clone(),
team_name,
user_name: None, client_id: None, redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
};
config
.set_or_update(profile_name.clone(), profile)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
save_config(&config_path, &config)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
let token_store = create_token_store()
.map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
let token_key = make_token_key(&team_id, &user_id);
token_store
.set(&token_key, &token)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save token: {}", e)))?;
println!("✓ Authentication successful!");
println!("Profile '{}' saved.", profile_name);
Ok(())
}
pub fn status(profile_name: Option<String>) -> Result<(), String> {
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
let config_path = default_config_path().map_err(|e| e.to_string())?;
let config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
println!("Profile: {}", profile_name);
println!("Team ID: {}", profile.team_id);
println!("User ID: {}", profile.user_id);
if let Some(team_name) = &profile.team_name {
println!("Team Name: {}", team_name);
}
if let Some(user_name) = &profile.user_name {
println!("User Name: {}", user_name);
}
if let Some(client_id) = &profile.client_id {
println!("Client ID: {}", client_id);
}
if std::env::var("SLACK_TOKEN").is_ok() {
println!("SLACK_TOKEN: set");
}
use crate::profile::FileTokenStore;
let file_path = FileTokenStore::default_path().map_err(|e| e.to_string())?;
println!("Token Store: file ({})", file_path.display());
let token_store = create_token_store().map_err(|e| e.to_string())?;
let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
let user_token_key = format!("{}:{}:user", &profile.team_id, &profile.user_id);
let has_bot_token = token_store.exists(&bot_token_key);
let has_user_token = token_store.exists(&user_token_key);
let mut available_tokens = Vec::new();
if has_bot_token {
available_tokens.push("Bot");
}
if has_user_token {
available_tokens.push("User");
}
if available_tokens.is_empty() {
println!("Tokens Available: None");
} else {
println!("Tokens Available: {}", available_tokens.join(", "));
}
if has_bot_token {
if let Ok(bot_token) = token_store.get(&bot_token_key) {
if let Some(bot_id) = extract_bot_id(&bot_token) {
println!("Bot ID: {}", bot_id);
}
}
}
if let Some(bot_scopes) = profile.get_bot_scopes() {
if !bot_scopes.is_empty() {
println!("Bot Scopes: {}", bot_scopes.join(", "));
}
}
if let Some(user_scopes) = profile.get_user_scopes() {
if !user_scopes.is_empty() {
println!("User Scopes: {}", user_scopes.join(", "));
}
}
let default_token_type =
compute_default_token_type_display(profile.default_token_type, has_user_token);
println!("Default Token Type: {}", default_token_type);
Ok(())
}
fn compute_default_token_type_display(
profile_default_token_type: Option<crate::profile::TokenType>,
has_user_token: bool,
) -> &'static str {
if let Some(token_type) = profile_default_token_type {
match token_type {
crate::profile::TokenType::Bot => "Bot",
crate::profile::TokenType::User => "User",
}
} else if has_user_token {
"User"
} else {
"Bot"
}
}
pub fn compute_initial_default_token_type(
existing_default_token_type: Option<crate::profile::TokenType>,
has_user_token: bool,
) -> crate::profile::TokenType {
if let Some(token_type) = existing_default_token_type {
return token_type;
}
if has_user_token {
crate::profile::TokenType::User
} else {
crate::profile::TokenType::Bot
}
}
fn extract_bot_id(token: &str) -> Option<String> {
if token.starts_with("xoxb-") {
let parts: Vec<&str> = token.split('-').collect();
if parts.len() >= 3 {
return Some(parts[2].to_string());
}
}
None
}
pub fn list() -> Result<(), String> {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let config = load_config(&config_path).map_err(|e| e.to_string())?;
if config.profiles.is_empty() {
println!("No profiles found.");
return Ok(());
}
println!("Profiles:");
for name in config.list_names() {
if let Some(profile) = config.get(&name) {
let team_name = profile.team_name.as_deref().unwrap_or(&profile.team_id);
println!(
" {}: {} ({}:{})",
name, team_name, profile.team_id, profile.user_id
);
}
}
Ok(())
}
pub fn rename(old_name: String, new_name: String) -> Result<(), String> {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&old_name)
.ok_or_else(|| format!("Profile '{}' not found", old_name))?
.clone();
if config.get(&new_name).is_some() {
return Err(format!("Profile '{}' already exists", new_name));
}
config.remove(&old_name);
config.set(new_name.clone(), profile);
save_config(&config_path, &config).map_err(|e| e.to_string())?;
println!("Profile '{}' renamed to '{}'", old_name, new_name);
Ok(())
}
pub fn logout(profile_name: Option<String>) -> Result<(), String> {
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
let config_path = default_config_path().map_err(|e| e.to_string())?;
let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?
.clone();
let token_store = create_token_store().map_err(|e| e.to_string())?;
let token_key = make_token_key(&profile.team_id, &profile.user_id);
let _ = token_store.delete(&token_key);
config.remove(&profile_name);
save_config(&config_path, &config).map_err(|e| e.to_string())?;
println!("Profile '{}' removed", profile_name);
Ok(())
}
fn open_browser(url: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
let result = Command::new("open").arg(url).spawn();
#[cfg(target_os = "linux")]
let result = Command::new("xdg-open").arg(url).spawn();
#[cfg(target_os = "windows")]
let result = Command::new("cmd").args(["/C", "start", url]).spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Unsupported platform",
));
result.map(|_| ()).map_err(|e| e.to_string())
}
fn find_cloudflared() -> Option<String> {
if Command::new("cloudflared")
.arg("--version")
.output()
.is_ok()
{
return Some("cloudflared".to_string());
}
let common_paths = [
"/usr/local/bin/cloudflared",
"/opt/homebrew/bin/cloudflared",
"/usr/bin/cloudflared",
];
for path in &common_paths {
if std::path::Path::new(path).exists() {
return Some(path.to_string());
}
}
None
}
fn generate_and_save_manifest(
redirect_uri: &str,
bot_scopes: &[String],
user_scopes: &[String],
profile_name: &str,
) -> Result<PathBuf, OAuthError> {
use crate::auth::manifest::generate_manifest;
use std::fs;
let manifest_yaml = generate_manifest(bot_scopes, user_scopes, redirect_uri, profile_name)
.map_err(|e| OAuthError::ConfigError(format!("Failed to generate manifest: {}", e)))?;
let home = directories::BaseDirs::new()
.ok_or_else(|| OAuthError::ConfigError("Failed to determine home directory".to_string()))?
.home_dir()
.to_path_buf();
let config_dir = home.join(".config").join("slack-rs");
fs::create_dir_all(&config_dir).map_err(|e| {
OAuthError::ConfigError(format!("Failed to create config directory: {}", e))
})?;
let manifest_path = config_dir.join(format!("{}_manifest.yml", profile_name));
fs::write(&manifest_path, &manifest_yaml)
.map_err(|e| OAuthError::ConfigError(format!("Failed to write manifest file: {}", e)))?;
use crate::auth::clipboard::{copy_to_clipboard, ClipboardResult};
match copy_to_clipboard(&manifest_yaml) {
ClipboardResult::Success(method) => {
println!("✓ Manifest copied to clipboard ({})!", method);
}
ClipboardResult::Failed => {
eprintln!("⚠️ Warning: Could not copy to clipboard.");
eprintln!(" Please manually copy from: {}", manifest_path.display());
}
}
Ok(manifest_path)
}
#[allow(dead_code)]
pub struct ExtendedLoginOptions {
pub client_id: Option<String>,
pub profile_name: Option<String>,
pub redirect_uri: String,
pub bot_scopes: Option<Vec<String>>,
pub user_scopes: Option<Vec<String>>,
pub cloudflared_path: Option<String>,
pub ngrok_path: Option<String>,
pub base_url: Option<String>,
}
pub async fn login_with_credentials_extended(
bot_scopes: Vec<String>,
user_scopes: Vec<String>,
profile_name: Option<String>,
use_cloudflared: bool,
) -> Result<(), OAuthError> {
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
if debug::enabled() {
debug::log(format!(
"login_with_credentials_extended: profile={}, bot_scopes_count={}, user_scopes_count={}",
profile_name,
bot_scopes.len(),
user_scopes.len()
));
}
let port = resolve_callback_port()?;
let final_redirect_uri: String;
let mut cloudflared_tunnel: Option<CloudflaredTunnel> = None;
if use_cloudflared {
let path = match find_cloudflared() {
Some(p) => p,
None => {
return Err(OAuthError::ConfigError(
"cloudflared not found. Please install it first:\n \
macOS: brew install cloudflare/cloudflare/cloudflared\n \
Linux: See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
.to_string(),
));
}
};
println!("Starting cloudflared tunnel...");
let local_url = format!("http://localhost:{}", port);
match CloudflaredTunnel::start(&path, &local_url, 30) {
Ok(mut t) => {
let public_url = t.public_url().to_string();
println!("✓ Tunnel started: {}", public_url);
println!(" Tunneling {} -> {}", public_url, local_url);
if !t.is_running() {
return Err(OAuthError::ConfigError(
"Cloudflared tunnel started but process is not running".to_string(),
));
}
final_redirect_uri = format!("{}/callback", public_url);
println!("Using redirect URI: {}", final_redirect_uri);
cloudflared_tunnel = Some(t);
}
Err(CloudflaredError::StartError(msg)) => {
return Err(OAuthError::ConfigError(format!(
"Failed to start cloudflared: {}",
msg
)));
}
Err(CloudflaredError::UrlExtractionError(msg)) => {
return Err(OAuthError::ConfigError(format!(
"Failed to extract cloudflared URL: {}",
msg
)));
}
Err(e) => {
return Err(OAuthError::ConfigError(format!(
"Cloudflared error: {:?}",
e
)));
}
}
} else {
final_redirect_uri = format!("http://localhost:{}/callback", port);
}
let manifest_path = generate_and_save_manifest(
&final_redirect_uri,
&bot_scopes,
&user_scopes,
&profile_name,
)?;
println!("\n📋 Slack App Manifest saved to:");
println!(" {}", manifest_path.display());
println!("\n🔧 Setup Instructions:");
println!(" 1. Go to https://api.slack.com/apps");
println!(" 2. Click 'Create New App' → 'From an app manifest'");
println!(" 3. Select your workspace");
println!(" 4. Copy and paste the manifest from the file above");
println!(" 5. Click 'Create'");
println!(" 6. Go to 'Basic Information' → 'App Credentials'");
println!(" You will need the Client ID and Client Secret below.");
println!("\n⏸️ Press Enter when you've created the app...");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
if let Some(ref mut tunnel) = cloudflared_tunnel {
if !tunnel.is_running() {
return Err(OAuthError::ConfigError(
"Cloudflared tunnel stopped unexpectedly".to_string(),
));
}
println!("✓ Tunnel is running");
}
let token_store = create_token_store()
.map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
println!("\n🔑 Enter credentials from 'Basic Information' → 'App Credentials':");
let client_id = {
let config_path = default_config_path()
.map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
let existing_config = load_config(&config_path).ok();
let existing_profile = existing_config.as_ref().and_then(|c| c.get(&profile_name));
if let Some(saved_id) = existing_profile.and_then(|p| p.client_id.as_ref()) {
println!("Using saved Client ID: {}", saved_id);
saved_id.clone()
} else {
print!("Enter Slack Client ID: ");
io::stdout()
.flush()
.map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
let mut id_input = String::new();
io::stdin()
.read_line(&mut id_input)
.map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
id_input.trim().to_string()
}
};
let client_secret = resolve_client_secret(&*token_store, &profile_name, false)?;
let config = OAuthConfig {
client_id: client_id.clone(),
client_secret: client_secret.clone(),
redirect_uri: final_redirect_uri.clone(),
scopes: bot_scopes.clone(),
user_scopes: user_scopes.clone(),
};
println!("\n🔄 Starting OAuth flow...");
println!("⚠️ IMPORTANT: Do NOT click 'Install to Workspace' manually!");
println!(" The OAuth flow will handle installation automatically.\n");
let (team_id, team_name, user_id, bot_token, user_token) =
perform_oauth_flow(&config, None).await?;
if debug::enabled() {
debug::log(format!(
"OAuth flow completed: team_id={}, user_id={}, team_name={:?}",
team_id, user_id, team_name
));
debug::log(format!(
"tokens: bot_token_present={}, user_token_present={}",
bot_token.is_some(),
user_token.is_some()
));
if let Some(ref token) = bot_token {
debug::log(format!("bot_token={}", debug::token_hint(token)));
}
if let Some(ref token) = user_token {
debug::log(format!("user_token={}", debug::token_hint(token)));
}
}
println!("💾 Saving profile and credentials...");
save_profile_and_credentials(SaveCredentials {
config_path: &default_config_path()
.map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?,
profile_name: &profile_name,
team_id: &team_id,
team_name: &team_name,
user_id: &user_id,
bot_token: bot_token.as_deref(),
user_token: user_token.as_deref(),
client_id: &client_id,
client_secret: &client_secret,
redirect_uri: &final_redirect_uri,
scopes: &bot_scopes,
bot_scopes: &bot_scopes,
user_scopes: &user_scopes,
})?;
println!("\n✅ Login successful!");
println!("Profile '{}' has been saved.", profile_name);
drop(cloudflared_tunnel);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::TokenStore;
#[test]
fn test_status_profile_not_found() {
let result = status(Some("nonexistent".to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_extract_bot_id_valid() {
let token = "xoxb-T123-B456-secret123";
assert_eq!(extract_bot_id(token), Some("B456".to_string()));
}
#[test]
fn test_extract_bot_id_invalid() {
assert_eq!(extract_bot_id("xoxp-user-token"), None);
assert_eq!(extract_bot_id("xoxb-only"), None);
assert_eq!(extract_bot_id("xoxb-T123"), None);
assert_eq!(extract_bot_id("not-a-token"), None);
assert_eq!(extract_bot_id(""), None);
}
#[test]
fn test_extract_bot_id_edge_cases() {
assert_eq!(
extract_bot_id("xoxb-123456-789012-abcdef"),
Some("789012".to_string())
);
assert_eq!(
extract_bot_id("xoxb-T123-B456-secret123"),
Some("B456".to_string())
);
assert_eq!(
extract_bot_id("xoxb-T123-B456-secret-with-dashes"),
Some("B456".to_string())
);
}
#[test]
fn test_list_empty() {
let result = list();
assert!(result.is_ok());
}
#[test]
fn test_rename_nonexistent_profile() {
let result = rename("nonexistent".to_string(), "new_name".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_logout_nonexistent_profile() {
let result = logout(Some("nonexistent".to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
#[serial_test::serial]
fn test_save_profile_and_credentials_with_client_id() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let team_id = "T123";
let user_id = "U456";
let profile_name = "test";
let tokens_path = temp_dir.path().join("tokens.json");
std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
let scopes = vec!["chat:write".to_string(), "users:read".to_string()];
let bot_scopes = vec!["chat:write".to_string()];
let user_scopes = vec!["users:read".to_string()];
save_profile_and_credentials(SaveCredentials {
config_path: &config_path,
profile_name,
team_id,
team_name: &Some("Test Team".to_string()),
user_id,
bot_token: Some("xoxb-test-bot-token"),
user_token: Some("xoxp-test-user-token"),
client_id: "test-client-id",
client_secret: "test-client-secret",
redirect_uri: "http://127.0.0.1:8765/callback",
scopes: &scopes,
bot_scopes: &bot_scopes,
user_scopes: &user_scopes,
})
.unwrap();
let config = load_config(&config_path).unwrap();
let profile = config.get(profile_name).unwrap();
assert_eq!(profile.client_id, Some("test-client-id".to_string()));
assert_eq!(profile.team_id, team_id);
assert_eq!(profile.user_id, user_id);
use crate::profile::FileTokenStore;
let token_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
let bot_token_key = make_token_key(team_id, user_id);
let user_token_key = format!("{}:{}:user", team_id, user_id);
let client_secret_key = format!("oauth-client-secret:{}", profile_name);
assert!(token_store.exists(&bot_token_key));
assert!(token_store.exists(&user_token_key));
assert!(token_store.exists(&client_secret_key));
std::env::remove_var("SLACK_RS_TOKENS_PATH");
}
#[test]
#[serial_test::serial]
fn test_save_profile_and_credentials_sets_default_token_type_user() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let tokens_path = temp_dir.path().join("tokens.json");
std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
let team_id = "T123";
let user_id = "U456";
let profile_name = "test";
let scopes = vec!["chat:write".to_string()];
let bot_scopes = vec!["chat:write".to_string()];
let user_scopes = vec!["users:read".to_string()];
save_profile_and_credentials(SaveCredentials {
config_path: &config_path,
profile_name,
team_id,
team_name: &Some("Test Team".to_string()),
user_id,
bot_token: Some("xoxb-test-bot-token"),
user_token: Some("xoxp-test-user-token"), client_id: "test-client-id",
client_secret: "test-client-secret",
redirect_uri: "http://127.0.0.1:8765/callback",
scopes: &scopes,
bot_scopes: &bot_scopes,
user_scopes: &user_scopes,
})
.unwrap();
let config = load_config(&config_path).unwrap();
let profile = config.get(profile_name).unwrap();
assert_eq!(
profile.default_token_type,
Some(crate::profile::TokenType::User)
);
std::env::remove_var("SLACK_RS_TOKENS_PATH");
}
#[test]
#[serial_test::serial]
fn test_save_profile_and_credentials_sets_default_token_type_bot() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let tokens_path = temp_dir.path().join("tokens.json");
std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
let team_id = "T123";
let user_id = "U456";
let profile_name = "test";
let scopes = vec!["chat:write".to_string()];
let bot_scopes = vec!["chat:write".to_string()];
let user_scopes = vec!["users:read".to_string()];
save_profile_and_credentials(SaveCredentials {
config_path: &config_path,
profile_name,
team_id,
team_name: &Some("Test Team".to_string()),
user_id,
bot_token: Some("xoxb-test-bot-token"),
user_token: None, client_id: "test-client-id",
client_secret: "test-client-secret",
redirect_uri: "http://127.0.0.1:8765/callback",
scopes: &scopes,
bot_scopes: &bot_scopes,
user_scopes: &user_scopes,
})
.unwrap();
let config = load_config(&config_path).unwrap();
let profile = config.get(profile_name).unwrap();
assert_eq!(
profile.default_token_type,
Some(crate::profile::TokenType::Bot)
);
std::env::remove_var("SLACK_RS_TOKENS_PATH");
}
#[test]
#[serial_test::serial]
fn test_save_profile_and_credentials_preserves_existing_default_token_type() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let tokens_path = temp_dir.path().join("tokens.json");
std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
let team_id = "T123";
let user_id = "U456";
let profile_name = "test";
let mut config = ProfilesConfig::new();
config.set(
profile_name.to_string(),
Profile {
team_id: team_id.to_string(),
user_id: user_id.to_string(),
team_name: Some("Test Team".to_string()),
user_name: None,
client_id: Some("test-client-id".to_string()),
redirect_uri: Some("http://127.0.0.1:8765/callback".to_string()),
scopes: Some(vec!["chat:write".to_string()]),
bot_scopes: Some(vec!["chat:write".to_string()]),
user_scopes: Some(vec!["users:read".to_string()]),
default_token_type: Some(crate::profile::TokenType::Bot),
},
);
save_config(&config_path, &config).unwrap();
let scopes = vec!["chat:write".to_string()];
let bot_scopes = vec!["chat:write".to_string()];
let user_scopes = vec!["users:read".to_string()];
save_profile_and_credentials(SaveCredentials {
config_path: &config_path,
profile_name,
team_id,
team_name: &Some("Test Team".to_string()),
user_id,
bot_token: Some("xoxb-test-bot-token"),
user_token: Some("xoxp-test-user-token"), client_id: "test-client-id",
client_secret: "test-client-secret",
redirect_uri: "http://127.0.0.1:8765/callback",
scopes: &scopes,
bot_scopes: &bot_scopes,
user_scopes: &user_scopes,
})
.unwrap();
let config = load_config(&config_path).unwrap();
let profile = config.get(profile_name).unwrap();
assert_eq!(
profile.default_token_type,
Some(crate::profile::TokenType::Bot),
"Existing default_token_type should be preserved"
);
std::env::remove_var("SLACK_RS_TOKENS_PATH");
}
#[test]
fn test_backward_compatibility_load_profile_without_client_id() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let mut config = ProfilesConfig::new();
config.set(
"legacy".to_string(),
Profile {
team_id: "T999".to_string(),
user_id: "U888".to_string(),
team_name: Some("Legacy Team".to_string()),
user_name: Some("Legacy User".to_string()),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
save_config(&config_path, &config).unwrap();
let loaded_config = load_config(&config_path).unwrap();
let profile = loaded_config.get("legacy").unwrap();
assert_eq!(profile.client_id, None);
assert_eq!(profile.team_id, "T999");
}
#[test]
fn test_bot_and_user_token_storage_keys() {
use crate::profile::InMemoryTokenStore;
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
let bot_token = "xoxb-test-bot-token";
let user_token = "xoxp-test-user-token";
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();
token_store.set(&user_token_key, user_token).unwrap();
assert_eq!(token_store.get(&bot_token_key).unwrap(), bot_token);
assert_eq!(bot_token_key, "T123:U456");
assert_eq!(token_store.get(&user_token_key).unwrap(), user_token);
assert_eq!(user_token_key, "T123:U456:user");
assert_ne!(bot_token_key, user_token_key);
}
#[test]
#[serial_test::serial]
fn test_status_shows_token_store_backend_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let tokens_path = temp_dir.path().join("tokens.json");
std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
let mut config = ProfilesConfig::new();
config.set(
"test".to_string(),
Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
save_config(&config_path, &config).unwrap();
std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
std::env::remove_var("SLACK_RS_TOKENS_PATH");
std::env::remove_var("SLACK_RS_CONFIG_PATH");
}
#[test]
#[serial_test::serial]
fn test_status_shows_slack_token_env_when_set() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("profiles.json");
let mut config = ProfilesConfig::new();
config.set(
"test".to_string(),
Profile {
team_id: "T123".to_string(),
user_id: "U456".to_string(),
team_name: Some("Test Team".to_string()),
user_name: None,
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: None,
},
);
save_config(&config_path, &config).unwrap();
std::env::set_var("SLACK_TOKEN", "xoxb-secret-token");
std::env::remove_var("SLACK_TOKEN");
}
#[test]
fn test_status_default_token_type_user_set() {
let result = compute_default_token_type_display(
Some(crate::profile::TokenType::User),
false, );
assert_eq!(result, "User");
}
#[test]
fn test_status_default_token_type_bot_set() {
let result = compute_default_token_type_display(
Some(crate::profile::TokenType::Bot),
true, );
assert_eq!(result, "Bot");
}
#[test]
fn test_status_default_token_type_fallback_with_user_token() {
let result = compute_default_token_type_display(None, true);
assert_eq!(result, "User");
}
#[test]
fn test_status_default_token_type_fallback_without_user_token() {
let result = compute_default_token_type_display(None, false);
assert_eq!(result, "Bot");
}
#[test]
fn test_status_default_token_type_user_overrides_inference() {
let result = compute_default_token_type_display(
Some(crate::profile::TokenType::User),
false, );
assert_eq!(result, "User");
}
#[test]
fn test_status_default_token_type_bot_overrides_inference() {
let result = compute_default_token_type_display(
Some(crate::profile::TokenType::Bot),
true, );
assert_eq!(result, "Bot");
}
#[test]
fn test_compute_initial_default_token_type_new_profile_with_user_token() {
let result = compute_initial_default_token_type(None, true);
assert_eq!(result, crate::profile::TokenType::User);
}
#[test]
fn test_compute_initial_default_token_type_new_profile_without_user_token() {
let result = compute_initial_default_token_type(None, false);
assert_eq!(result, crate::profile::TokenType::Bot);
}
#[test]
fn test_compute_initial_default_token_type_preserves_existing_bot() {
let result = compute_initial_default_token_type(
Some(crate::profile::TokenType::Bot),
true, );
assert_eq!(result, crate::profile::TokenType::Bot);
}
#[test]
fn test_compute_initial_default_token_type_preserves_existing_user() {
let result = compute_initial_default_token_type(
Some(crate::profile::TokenType::User),
false, );
assert_eq!(result, crate::profile::TokenType::User);
}
#[test]
#[serial_test::serial]
fn test_file_token_store_respects_xdg_data_home() {
use crate::profile::FileTokenStore;
use tempfile::TempDir;
std::env::remove_var("SLACK_RS_TOKENS_PATH");
let temp_dir = TempDir::new().unwrap();
let xdg_data_home = temp_dir.path().to_str().unwrap();
std::env::set_var("XDG_DATA_HOME", xdg_data_home);
let path = FileTokenStore::default_path().unwrap();
let expected = temp_dir.path().join("slack-rs").join("tokens.json");
assert_eq!(
path, expected,
"auth status should display XDG_DATA_HOME-based path when XDG_DATA_HOME is set"
);
std::env::remove_var("XDG_DATA_HOME");
}
#[test]
#[serial_test::serial]
fn test_file_token_store_slack_rs_tokens_path_priority() {
use crate::profile::FileTokenStore;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let custom_path = temp_dir.path().join("custom-tokens.json");
let xdg_data_home = temp_dir.path().join("xdg-data");
std::env::set_var("SLACK_RS_TOKENS_PATH", custom_path.to_str().unwrap());
std::env::set_var("XDG_DATA_HOME", xdg_data_home.to_str().unwrap());
let path = FileTokenStore::default_path().unwrap();
assert_eq!(
path, custom_path,
"auth status should display SLACK_RS_TOKENS_PATH when both env vars are set"
);
std::env::remove_var("SLACK_RS_TOKENS_PATH");
std::env::remove_var("XDG_DATA_HOME");
}
}