use crate::oauth::OAuthError;
use crate::profile::{
create_token_store, default_config_path, delete_oauth_client_secret, get_oauth_client_secret,
load_config, save_config, store_oauth_client_secret, Profile, ProfilesConfig, TokenStoreError,
TokenType,
};
use std::io::IsTerminal;
pub struct OAuthSetParams {
pub profile_name: String,
pub client_id: String,
pub redirect_uri: String,
pub scopes: String,
pub client_secret_env: Option<String>,
pub client_secret_file: Option<String>,
pub client_secret: Option<String>,
pub confirmed: bool,
}
#[derive(Default)]
struct ClientSecretOptions {
env_var: Option<String>,
file_path: Option<String>,
direct_value: Option<String>,
confirmed: bool,
}
fn resolve_client_secret(options: ClientSecretOptions) -> Result<String, OAuthError> {
if let Some(env_var) = options.env_var {
if let Ok(secret) = std::env::var(&env_var) {
return Ok(secret);
} else {
return Err(OAuthError::ConfigError(format!(
"Environment variable '{}' not found or empty",
env_var
)));
}
}
if let Ok(secret) = std::env::var("SLACKRS_CLIENT_SECRET") {
return Ok(secret);
}
if let Some(file_path) = options.file_path {
let secret = std::fs::read_to_string(&file_path).map_err(|e| {
OAuthError::ConfigError(format!("Failed to read file '{}': {}", file_path, e))
})?;
return Ok(secret.trim().to_string());
}
if let Some(secret) = options.direct_value {
if !options.confirmed {
return Err(OAuthError::ConfigError(
"Using --client-secret is unsafe (visible in shell history/process list).\n\
Available safer alternatives:\n\
- Set environment variable: SLACKRS_CLIENT_SECRET=<secret>\n\
- Use flag: --client-secret-env <ENV_VAR>\n\
- Use flag: --client-secret-file <PATH>\n\
- Interactive input (run without flags in a terminal)\n\
- Use --yes to confirm direct input (not recommended)"
.to_string(),
));
}
return Ok(secret);
}
if std::io::stdin().is_terminal() {
let secret = rpassword::prompt_password("Enter OAuth client secret: ")
.map_err(|e| OAuthError::ConfigError(format!("Failed to read password: {}", e)))?;
return Ok(secret);
}
Err(OAuthError::ConfigError(
"No client secret provided and running in non-interactive mode.\n\
Available options:\n\
- Set environment variable: SLACKRS_CLIENT_SECRET=<secret>\n\
- Use flag: --client-secret-env <ENV_VAR>\n\
- Use flag: --client-secret-file <PATH>\n\
- Use flag: --client-secret <SECRET> --yes (unsafe, not recommended)"
.to_string(),
))
}
pub fn oauth_set(params: OAuthSetParams) -> Result<(), OAuthError> {
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 client_secret = resolve_client_secret(ClientSecretOptions {
env_var: params.client_secret_env,
file_path: params.client_secret_file,
direct_value: params.client_secret,
confirmed: params.confirmed,
})?;
if client_secret.trim().is_empty() {
return Err(OAuthError::ConfigError(
"Client secret cannot be empty".to_string(),
));
}
let scopes_vec: Vec<String> = params
.scopes
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if scopes_vec.is_empty() {
return Err(OAuthError::ConfigError(
"At least one scope is required".to_string(),
));
}
let scopes_vec = crate::oauth::expand_scopes(&scopes_vec);
let profile = if let Some(existing) = config.get(¶ms.profile_name) {
Profile {
team_id: existing.team_id.clone(),
user_id: existing.user_id.clone(),
team_name: existing.team_name.clone(),
user_name: existing.user_name.clone(),
client_id: Some(params.client_id.clone()),
redirect_uri: Some(params.redirect_uri.clone()),
scopes: Some(scopes_vec.clone()),
bot_scopes: None, user_scopes: None, default_token_type: existing.default_token_type,
}
} else {
Profile {
team_id: "PLACEHOLDER".to_string(),
user_id: "PLACEHOLDER".to_string(),
team_name: None,
user_name: None,
client_id: Some(params.client_id.clone()),
redirect_uri: Some(params.redirect_uri.clone()),
scopes: Some(scopes_vec.clone()),
bot_scopes: None, user_scopes: None, default_token_type: None,
}
};
config.set(params.profile_name.clone(), profile);
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)))?;
store_oauth_client_secret(&*token_store, ¶ms.profile_name, &client_secret)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save client secret: {}", e)))?;
println!(
"✓ OAuth configuration saved for profile '{}'",
params.profile_name
);
println!(" Client ID: {}", params.client_id);
println!(" Redirect URI: {}", params.redirect_uri);
println!(" Scopes: {}", scopes_vec.join(", "));
println!(" Client secret: (saved securely in token store)");
Ok(())
}
pub fn oauth_show(profile_name: String) -> Result<(), OAuthError> {
let config_path = default_config_path()
.map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
let config = load_config(&config_path)
.map_err(|e| OAuthError::ConfigError(format!("Failed to load config: {}", e)))?;
let profile = config
.get(&profile_name)
.ok_or_else(|| OAuthError::ConfigError(format!("Profile '{}' not found", profile_name)))?;
println!("OAuth configuration for profile '{}':", profile_name);
if let Some(client_id) = &profile.client_id {
println!(" Client ID: {}", client_id);
} else {
println!(" Client ID: (not set)");
}
if let Some(redirect_uri) = &profile.redirect_uri {
println!(" Redirect URI: {}", redirect_uri);
} else {
println!(" Redirect URI: (not set)");
}
if let Some(scopes) = &profile.scopes {
println!(" Scopes: {}", scopes.join(", "));
} else {
println!(" Scopes: (not set)");
}
let token_store = create_token_store()
.map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
let has_secret = get_oauth_client_secret(&*token_store, &profile_name).is_ok();
println!(
" Client secret: {}",
if has_secret {
"(saved in token store)"
} else {
"(not set)"
}
);
Ok(())
}
pub fn oauth_delete(profile_name: String) -> Result<(), OAuthError> {
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)
.map_err(|e| OAuthError::ConfigError(format!("Failed to load config: {}", e)))?;
let profile = config
.get(&profile_name)
.ok_or_else(|| OAuthError::ConfigError(format!("Profile '{}' not found", profile_name)))?;
let updated_profile = Profile {
team_id: profile.team_id.clone(),
user_id: profile.user_id.clone(),
team_name: profile.team_name.clone(),
user_name: profile.user_name.clone(),
client_id: None,
redirect_uri: None,
scopes: None,
bot_scopes: None,
user_scopes: None,
default_token_type: profile.default_token_type,
};
config.set(profile_name.clone(), updated_profile);
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)))?;
match delete_oauth_client_secret(&*token_store, &profile_name) {
Ok(_) => {} Err(TokenStoreError::NotFound(_)) => {} Err(e) => {
return Err(OAuthError::ConfigError(format!(
"Failed to delete client secret: {}",
e
)))
}
}
println!(
"✓ OAuth configuration deleted for profile '{}'",
profile_name
);
Ok(())
}
pub fn set_default_token_type(
profile_name: String,
token_type: TokenType,
) -> Result<(), OAuthError> {
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 = config
.get(&profile_name)
.ok_or_else(|| OAuthError::ConfigError(format!("Profile '{}' not found", profile_name)))?
.clone();
let updated_profile = Profile {
team_id: profile.team_id,
user_id: profile.user_id,
team_name: profile.team_name,
user_name: profile.user_name,
client_id: profile.client_id,
redirect_uri: profile.redirect_uri,
scopes: profile.scopes,
bot_scopes: profile.bot_scopes,
user_scopes: profile.user_scopes,
default_token_type: Some(token_type),
};
config.set(profile_name.clone(), updated_profile);
save_config(&config_path, &config)
.map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
println!(
"✓ Default token type set to '{}' for profile '{}'",
token_type, profile_name
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oauth_show_profile_not_found() {
let result = oauth_show("nonexistent".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_oauth_delete_profile_not_found() {
let result = oauth_delete("nonexistent".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_oauth_show_does_not_leak_client_secret() {
use crate::profile::{store_oauth_client_secret, InMemoryTokenStore};
let token_store = InMemoryTokenStore::new();
let profile_name = "test-profile";
let client_secret = "super-secret-value-12345";
store_oauth_client_secret(&token_store, profile_name, client_secret).unwrap();
assert_eq!(
crate::profile::get_oauth_client_secret(&token_store, profile_name).unwrap(),
client_secret
);
let has_secret =
crate::profile::get_oauth_client_secret(&token_store, profile_name).is_ok();
assert!(has_secret);
let output = if has_secret {
"(saved in file store)"
} else {
"(not set)"
};
assert!(!output.contains(client_secret));
assert!(output == "(saved in file store)" || output == "(not set)");
}
#[test]
#[serial_test::serial]
fn test_resolve_client_secret_priority() {
use std::env;
use std::fs;
use tempfile::NamedTempFile;
env::set_var("CUSTOM_SECRET", "custom-env-secret");
env::set_var("SLACKRS_CLIENT_SECRET", "default-env-secret");
let result = resolve_client_secret(ClientSecretOptions {
env_var: Some("CUSTOM_SECRET".to_string()),
..Default::default()
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "custom-env-secret");
let result = resolve_client_secret(ClientSecretOptions::default());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "default-env-secret");
env::remove_var("CUSTOM_SECRET");
env::remove_var("SLACKRS_CLIENT_SECRET");
let temp_file = NamedTempFile::new().unwrap();
fs::write(temp_file.path(), "file-secret\n").unwrap();
let result = resolve_client_secret(ClientSecretOptions {
file_path: Some(temp_file.path().to_str().unwrap().to_string()),
..Default::default()
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "file-secret");
let result = resolve_client_secret(ClientSecretOptions {
direct_value: Some("direct-secret".to_string()),
..Default::default()
});
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Use --yes to confirm"));
let result = resolve_client_secret(ClientSecretOptions {
direct_value: Some("direct-secret".to_string()),
confirmed: true,
..Default::default()
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "direct-secret");
let result = resolve_client_secret(ClientSecretOptions::default());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No client secret provided"));
}
#[test]
#[serial_test::serial]
fn test_resolve_client_secret_explicit_env_precedence() {
use std::env;
env::set_var("CUSTOM_VAR", "custom-value");
env::set_var("SLACKRS_CLIENT_SECRET", "default-value");
let result = resolve_client_secret(ClientSecretOptions {
env_var: Some("CUSTOM_VAR".to_string()),
..Default::default()
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "custom-value");
env::remove_var("CUSTOM_VAR");
env::remove_var("SLACKRS_CLIENT_SECRET");
}
#[test]
#[serial_test::serial]
fn test_resolve_client_secret_file_precedence() {
use std::env;
use std::fs;
use tempfile::NamedTempFile;
env::remove_var("SLACKRS_CLIENT_SECRET");
let temp_file = NamedTempFile::new().unwrap();
fs::write(temp_file.path(), "file-secret").unwrap();
let result = resolve_client_secret(ClientSecretOptions {
file_path: Some(temp_file.path().to_str().unwrap().to_string()),
direct_value: Some("direct-secret".to_string()),
confirmed: true,
..Default::default()
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), "file-secret");
}
#[test]
#[serial_test::serial]
fn test_resolve_client_secret_missing_env() {
use std::env;
env::remove_var("NONEXISTENT_VAR");
let result = resolve_client_secret(ClientSecretOptions {
env_var: Some("NONEXISTENT_VAR".to_string()),
..Default::default()
});
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Environment variable 'NONEXISTENT_VAR' not found"));
}
#[test]
#[serial_test::serial]
fn test_resolve_client_secret_missing_file() {
use std::env;
env::remove_var("SLACKRS_CLIENT_SECRET");
let result = resolve_client_secret(ClientSecretOptions {
file_path: Some("/nonexistent/path/to/secret".to_string()),
..Default::default()
});
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to read file"));
}
#[test]
#[serial_test::serial]
fn test_oauth_set_saves_to_file_backend() {
use crate::profile::{get_oauth_client_secret, FileTokenStore};
use std::env;
use std::fs;
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");
env::set_var("SLACKRS_CLIENT_SECRET", "test-secret-12345");
let token_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
let profile_name = "test-profile".to_string();
let _client_id = "123.456".to_string();
let _redirect_uri = "http://127.0.0.1:8765/callback".to_string();
let _scopes = "chat:write,users:read".to_string();
let client_secret = resolve_client_secret(ClientSecretOptions::default()).unwrap();
assert_eq!(client_secret, "test-secret-12345");
crate::profile::store_oauth_client_secret(&token_store, &profile_name, &client_secret)
.unwrap();
let retrieved_secret = get_oauth_client_secret(&token_store, &profile_name).unwrap();
assert_eq!(retrieved_secret, "test-secret-12345");
assert!(tokens_path.exists());
let tokens_content = fs::read_to_string(&tokens_path).unwrap();
assert!(tokens_content.contains("oauth-client-secret:test-profile"));
assert!(
!config_path.exists()
|| !fs::read_to_string(&config_path)
.unwrap_or_default()
.contains("test-secret-12345")
);
env::remove_var("SLACKRS_CLIENT_SECRET");
}
}