1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum OAuthError {
8 #[error("OAuth configuration error: {0}")]
9 ConfigError(String),
10
11 #[error("Network error: {0}")]
12 NetworkError(String),
13
14 #[error("HTTP error {0}: {1}")]
15 HttpError(u16, String),
16
17 #[error("Parse error: {0}")]
18 ParseError(String),
19
20 #[error("Slack API error: {0}")]
21 SlackError(String),
22
23 #[error("State mismatch: expected {expected}, got {actual}")]
24 StateMismatch { expected: String, actual: String },
25
26 #[error("Callback server error: {0}")]
27 ServerError(String),
28
29 #[error("Browser launch error: {0}")]
30 #[allow(dead_code)]
31 BrowserError(String),
32}
33
34#[derive(Debug, Clone)]
36pub struct OAuthConfig {
37 pub client_id: String,
38 pub client_secret: String,
39 pub redirect_uri: String,
40 pub scopes: Vec<String>,
42 pub user_scopes: Vec<String>,
44}
45
46impl OAuthConfig {
47 pub fn validate(&self) -> Result<(), OAuthError> {
49 if self.client_id.is_empty() {
50 return Err(OAuthError::ConfigError("client_id is required".to_string()));
51 }
52 if self.client_secret.is_empty() {
53 return Err(OAuthError::ConfigError(
54 "client_secret is required".to_string(),
55 ));
56 }
57 if self.redirect_uri.is_empty() {
58 return Err(OAuthError::ConfigError(
59 "redirect_uri is required".to_string(),
60 ));
61 }
62 if self.scopes.is_empty() && self.user_scopes.is_empty() {
63 return Err(OAuthError::ConfigError(
64 "at least one of bot scopes or user scopes is required".to_string(),
65 ));
66 }
67 Ok(())
68 }
69}
70
71#[derive(Debug, Serialize, Deserialize)]
73pub struct OAuthResponse {
74 pub ok: bool,
75 pub access_token: Option<String>,
76 pub token_type: Option<String>,
77 pub scope: Option<String>,
78 pub bot_user_id: Option<String>,
79 pub app_id: Option<String>,
80 pub team: Option<TeamInfo>,
81 pub authed_user: Option<AuthedUser>,
82 pub error: Option<String>,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86pub struct TeamInfo {
87 pub id: String,
88 pub name: String,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92pub struct AuthedUser {
93 pub id: String,
94 pub scope: Option<String>,
95 pub access_token: Option<String>,
96 pub token_type: Option<String>,
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn test_oauth_config_validation_success() {
105 let config = OAuthConfig {
106 client_id: "test_client_id".to_string(),
107 client_secret: "test_secret".to_string(),
108 redirect_uri: "http://localhost:8765/callback".to_string(),
109 scopes: vec!["chat:write".to_string()],
110 user_scopes: vec![],
111 };
112
113 assert!(config.validate().is_ok());
114 }
115
116 #[test]
117 fn test_oauth_config_validation_empty_client_id() {
118 let config = OAuthConfig {
119 client_id: "".to_string(),
120 client_secret: "test_secret".to_string(),
121 redirect_uri: "http://localhost:8765/callback".to_string(),
122 scopes: vec!["chat:write".to_string()],
123 user_scopes: vec![],
124 };
125
126 let result = config.validate();
127 assert!(result.is_err());
128 match result {
129 Err(OAuthError::ConfigError(msg)) => assert!(msg.contains("client_id")),
130 _ => panic!("Expected ConfigError"),
131 }
132 }
133
134 #[test]
135 fn test_oauth_config_validation_empty_client_secret() {
136 let config = OAuthConfig {
137 client_id: "test_client_id".to_string(),
138 client_secret: "".to_string(),
139 redirect_uri: "http://localhost:8765/callback".to_string(),
140 scopes: vec!["chat:write".to_string()],
141 user_scopes: vec![],
142 };
143
144 let result = config.validate();
145 assert!(result.is_err());
146 match result {
147 Err(OAuthError::ConfigError(msg)) => assert!(msg.contains("client_secret")),
148 _ => panic!("Expected ConfigError"),
149 }
150 }
151
152 #[test]
153 fn test_oauth_config_validation_empty_redirect_uri() {
154 let config = OAuthConfig {
155 client_id: "test_client_id".to_string(),
156 client_secret: "test_secret".to_string(),
157 redirect_uri: "".to_string(),
158 scopes: vec!["chat:write".to_string()],
159 user_scopes: vec![],
160 };
161
162 let result = config.validate();
163 assert!(result.is_err());
164 match result {
165 Err(OAuthError::ConfigError(msg)) => assert!(msg.contains("redirect_uri")),
166 _ => panic!("Expected ConfigError"),
167 }
168 }
169
170 #[test]
171 fn test_oauth_config_validation_empty_scopes() {
172 let config = OAuthConfig {
173 client_id: "test_client_id".to_string(),
174 client_secret: "test_secret".to_string(),
175 redirect_uri: "http://localhost:8765/callback".to_string(),
176 scopes: vec![],
177 user_scopes: vec![],
178 };
179
180 let result = config.validate();
181 assert!(result.is_err());
182 match result {
183 Err(OAuthError::ConfigError(msg)) => assert!(msg.contains("scopes")),
184 _ => panic!("Expected ConfigError"),
185 }
186 }
187
188 #[test]
189 fn test_oauth_response_deserialization() {
190 let json = r#"{
191 "ok": true,
192 "access_token": "xoxb-test-token",
193 "token_type": "bot",
194 "scope": "chat:write",
195 "bot_user_id": "U123",
196 "app_id": "A456",
197 "team": {
198 "id": "T789",
199 "name": "Test Team"
200 },
201 "authed_user": {
202 "id": "U012",
203 "scope": "users:read",
204 "access_token": "xoxp-test-token",
205 "token_type": "user"
206 }
207 }"#;
208
209 let response: OAuthResponse = serde_json::from_str(json).unwrap();
210 assert!(response.ok);
211 assert_eq!(response.access_token, Some("xoxb-test-token".to_string()));
212 assert_eq!(response.team.as_ref().unwrap().id, "T789");
213 assert_eq!(response.authed_user.as_ref().unwrap().id, "U012");
214 }
215}