pub mod pkce;
pub mod port;
pub mod scopes;
pub mod server;
pub mod types;
pub use pkce::{generate_pkce, generate_state};
pub use port::resolve_callback_port;
pub use scopes::{
all_scopes, bot_all_scopes, expand_scopes, expand_scopes_with_context, user_all_scopes,
};
pub use server::run_callback_server;
pub use types::{OAuthConfig, OAuthError, OAuthResponse};
use crate::debug;
use reqwest::Client;
use std::collections::HashMap;
pub async fn exchange_code(
config: &OAuthConfig,
code: &str,
code_verifier: &str,
base_url: Option<&str>,
) -> Result<OAuthResponse, OAuthError> {
let url = format!(
"{}/oauth.v2.access",
base_url.unwrap_or("https://slack.com/api")
);
let mut params = HashMap::new();
params.insert("client_id", config.client_id.as_str());
params.insert("client_secret", config.client_secret.as_str());
params.insert("code", code);
params.insert("redirect_uri", config.redirect_uri.as_str());
params.insert("code_verifier", code_verifier);
let client = Client::new();
let response = client
.post(&url)
.form(¶ms)
.send()
.await
.map_err(|e| OAuthError::NetworkError(e.to_string()))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| OAuthError::NetworkError(e.to_string()))?;
if !status.is_success() {
return Err(OAuthError::HttpError(status.as_u16(), body));
}
let oauth_response: OAuthResponse =
serde_json::from_str(&body).map_err(|e| OAuthError::ParseError(e.to_string()))?;
if debug::enabled() {
debug::log(format!(
"OAuth exchange response: ok={}, bot_token_present={}, authed_user_present={}",
oauth_response.ok,
oauth_response.access_token.is_some(),
oauth_response.authed_user.is_some()
));
debug::log(format!(
"OAuth exchange response body (redacted): {}",
debug::redact_json_secrets(&body)
));
}
if !oauth_response.ok {
return Err(OAuthError::SlackError(
oauth_response
.error
.unwrap_or_else(|| "unknown".to_string()),
));
}
Ok(oauth_response)
}
pub fn build_authorization_url(
config: &OAuthConfig,
code_challenge: &str,
state: &str,
) -> Result<String, OAuthError> {
let base_url = "https://slack.com/oauth/v2/authorize";
let mut url = url::Url::parse(base_url).map_err(|e| OAuthError::ParseError(e.to_string()))?;
let mut query = url.query_pairs_mut();
query
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", &config.redirect_uri)
.append_pair("code_challenge", code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", state);
if !config.scopes.is_empty() {
query.append_pair("scope", &config.scopes.join(","));
}
if !config.user_scopes.is_empty() {
query.append_pair("user_scope", &config.user_scopes.join(","));
}
drop(query);
if debug::enabled() {
debug::log("Authorization URL generated");
debug::log(format!("redirect_uri={}", config.redirect_uri));
debug::log(format!("bot_scopes_count={}", config.scopes.len()));
debug::log(format!("user_scopes_count={}", config.user_scopes.len()));
}
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_authorization_url() {
let config = OAuthConfig {
client_id: "test_client_id".to_string(),
client_secret: "test_secret".to_string(),
redirect_uri: "http://localhost:8765/callback".to_string(),
scopes: vec!["chat:write".to_string(), "users:read".to_string()],
user_scopes: vec![],
};
let code_challenge = "test_challenge";
let state = "test_state";
let url = build_authorization_url(&config, code_challenge, state).unwrap();
assert!(url.contains("client_id=test_client_id"));
assert!(url.contains("scope=chat%3Awrite%2Cusers%3Aread"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback"));
assert!(url.contains("code_challenge=test_challenge"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=test_state"));
}
#[test]
fn test_build_authorization_url_with_user_scope() {
let config = OAuthConfig {
client_id: "test_client_id".to_string(),
client_secret: "test_secret".to_string(),
redirect_uri: "http://localhost:8765/callback".to_string(),
scopes: vec!["chat:write".to_string()],
user_scopes: vec!["users:read".to_string(), "search:read".to_string()],
};
let code_challenge = "test_challenge";
let state = "test_state";
let url = build_authorization_url(&config, code_challenge, state).unwrap();
assert!(url.contains("client_id=test_client_id"));
assert!(url.contains("scope=chat%3Awrite"));
assert!(url.contains("user_scope=users%3Aread%2Csearch%3Aread"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback"));
assert!(url.contains("code_challenge=test_challenge"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=test_state"));
}
#[tokio::test]
async fn test_exchange_code_invalid_base_url() {
let config = OAuthConfig {
client_id: "test_client_id".to_string(),
client_secret: "test_secret".to_string(),
redirect_uri: "http://localhost:8765/callback".to_string(),
scopes: vec!["chat:write".to_string()],
user_scopes: vec![],
};
let result = exchange_code(
&config,
"test_code",
"test_verifier",
Some("http://invalid-url-that-does-not-exist"),
)
.await;
assert!(result.is_err());
match result {
Err(OAuthError::NetworkError(_)) => {}
_ => panic!("Expected NetworkError"),
}
}
}