1use serde::{Deserialize, Serialize};
7
8use crate::providers::endpoints;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum SocialProvider {
13 GitHub,
15 Google,
17}
18
19impl SocialProvider {
20 pub fn authorize_url(&self) -> &'static str {
22 match self {
23 Self::GitHub => endpoints::GITHUB_AUTHORIZE_URL,
24 Self::Google => endpoints::GOOGLE_AUTHORIZE_URL,
25 }
26 }
27
28 pub fn token_url(&self) -> &'static str {
30 match self {
31 Self::GitHub => endpoints::GITHUB_TOKEN_URL,
32 Self::Google => endpoints::GOOGLE_TOKEN_URL,
33 }
34 }
35
36 pub fn default_scopes(&self) -> &'static [&'static str] {
38 match self {
39 Self::GitHub => &["read:user", "user:email"],
40 Self::Google => &["openid", "email", "profile"],
41 }
42 }
43
44 pub fn user_info_url(&self) -> &'static str {
46 match self {
47 Self::GitHub => endpoints::GITHUB_USER_INFO_URL,
48 Self::Google => endpoints::GOOGLE_USER_INFO_URL,
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct SocialProviderConfig {
56 pub client_id: String,
58 pub client_secret: String,
60 pub redirect_uri: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SocialUserInfo {
67 pub provider: String,
69 pub provider_user_id: String,
71 pub email: String,
73 pub name: String,
75 pub avatar_url: Option<String>,
77}
78
79#[derive(Debug, thiserror::Error)]
81pub enum SocialError {
82 #[error("token exchange failed: {0}")]
84 TokenExchangeFailed(String),
85
86 #[error("user info fetch failed: {0}")]
88 UserInfoFailed(String),
89
90 #[error("missing required field: {0}")]
92 MissingField(String),
93
94 #[error("HTTP error: {0}")]
96 HttpError(String),
97}
98
99pub fn build_authorize_url(
101 provider: SocialProvider,
102 config: &SocialProviderConfig,
103 state: &str,
104) -> String {
105 let scopes = provider.default_scopes().join(" ");
106 let redirect = urlencoding::encode(&config.redirect_uri);
107 let state = urlencoding::encode(state);
108 format!(
109 "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code",
110 provider.authorize_url(),
111 config.client_id,
112 redirect,
113 scopes,
114 state,
115 )
116}
117
118pub async fn exchange_code(
120 client: &reqwest::Client,
121 provider: SocialProvider,
122 config: &SocialProviderConfig,
123 code: &str,
124) -> Result<String, SocialError> {
125 match provider {
126 SocialProvider::GitHub => {
127 let resp = client
128 .post(provider.token_url())
129 .header("Accept", "application/json")
130 .header("User-Agent", "oauth-toolkit")
131 .form(&[
132 ("client_id", config.client_id.as_str()),
133 ("client_secret", config.client_secret.as_str()),
134 ("code", code),
135 ("redirect_uri", config.redirect_uri.as_str()),
136 ])
137 .send()
138 .await
139 .map_err(|e| SocialError::HttpError(e.to_string()))?;
140
141 let body: serde_json::Value = resp
142 .json()
143 .await
144 .map_err(|e| SocialError::TokenExchangeFailed(e.to_string()))?;
145
146 body["access_token"]
147 .as_str()
148 .map(String::from)
149 .ok_or_else(|| {
150 SocialError::TokenExchangeFailed("no access_token in response".into())
151 })
152 }
153 SocialProvider::Google => {
154 let resp = client
155 .post(provider.token_url())
156 .form(&[
157 ("client_id", config.client_id.as_str()),
158 ("client_secret", config.client_secret.as_str()),
159 ("code", code),
160 ("redirect_uri", config.redirect_uri.as_str()),
161 ("grant_type", "authorization_code"),
162 ])
163 .send()
164 .await
165 .map_err(|e| SocialError::HttpError(e.to_string()))?;
166
167 let body: serde_json::Value = resp
168 .json()
169 .await
170 .map_err(|e| SocialError::TokenExchangeFailed(e.to_string()))?;
171
172 body["access_token"]
173 .as_str()
174 .map(String::from)
175 .ok_or_else(|| {
176 SocialError::TokenExchangeFailed("no access_token in response".into())
177 })
178 }
179 }
180}
181
182pub async fn fetch_user_info(
184 client: &reqwest::Client,
185 provider: SocialProvider,
186 access_token: &str,
187) -> Result<SocialUserInfo, SocialError> {
188 let resp = client
189 .get(provider.user_info_url())
190 .bearer_auth(access_token)
191 .send()
192 .await
193 .map_err(|e| SocialError::HttpError(e.to_string()))?;
194
195 let body: serde_json::Value = resp
196 .json()
197 .await
198 .map_err(|e| SocialError::UserInfoFailed(e.to_string()))?;
199
200 match provider {
201 SocialProvider::GitHub => {
202 let user_id = body["id"]
203 .as_str()
204 .or_else(|| body["id"].as_i64().map(|_| ""))
205 .ok_or(SocialError::MissingField("id".into()))?;
206 let email = body["email"].as_str().unwrap_or("").to_string();
207 let name = body["name"].as_str().unwrap_or("").to_string();
208 let avatar = body["avatar_url"].as_str().map(String::from);
209
210 Ok(SocialUserInfo {
211 provider: "github".into(),
212 provider_user_id: user_id.to_string(),
213 email,
214 name,
215 avatar_url: avatar,
216 })
217 }
218 SocialProvider::Google => {
219 let user_id = body["id"]
220 .as_str()
221 .ok_or(SocialError::MissingField("id".into()))?;
222 let email = body["email"]
223 .as_str()
224 .ok_or(SocialError::MissingField("email".into()))?;
225 let name = body["name"].as_str().unwrap_or("").to_string();
226 let avatar = body["picture"].as_str().map(String::from);
227
228 Ok(SocialUserInfo {
229 provider: "google".into(),
230 provider_user_id: user_id.to_string(),
231 email: email.to_string(),
232 name,
233 avatar_url: avatar,
234 })
235 }
236 }
237}
238
239pub fn sanitize_username(raw: &str) -> String {
242 let sanitized: String = raw
243 .chars()
244 .filter(|c| c.is_alphanumeric() || *c == '_')
245 .take(30)
246 .collect();
247
248 if sanitized.len() >= 3 {
249 sanitized
250 } else {
251 let nonce = crate::crypto::generate_opaque_token("", 4);
252 format!("user_{}", nonce)
253 }
254}
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used, clippy::expect_used)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn build_authorize_url_github() {
264 let config = SocialProviderConfig {
265 client_id: "id123".into(),
266 client_secret: "secret".into(),
267 redirect_uri: "https://app.com/callback".into(),
268 };
269 let url = build_authorize_url(SocialProvider::GitHub, &config, "state123");
270 assert!(url.contains("client_id=id123"));
271 assert!(url.contains("state=state123"));
272 assert!(url.contains("github.com/login/oauth/authorize"));
273 }
274
275 #[test]
276 fn build_authorize_url_google() {
277 let config = SocialProviderConfig {
278 client_id: "id456".into(),
279 client_secret: "secret".into(),
280 redirect_uri: "https://app.com/callback".into(),
281 };
282 let url = build_authorize_url(SocialProvider::Google, &config, "state456");
283 assert!(url.contains("accounts.google.com"));
284 assert!(url.contains("scope=openid"));
285 }
286
287 #[test]
288 fn sanitize_username_normal() {
289 assert_eq!(sanitize_username("john_doe"), "john_doe");
290 }
291
292 #[test]
293 fn sanitize_username_special_chars() {
294 let result = sanitize_username("!@#");
295 assert!(result.starts_with("user_"));
296 assert!(result.len() > 5);
297 }
298
299 #[test]
300 fn sanitize_username_too_short() {
301 let result = sanitize_username("ab");
302 assert!(result.starts_with("user_"));
303 }
304
305 #[test]
306 fn sanitize_username_long() {
307 let long = "a".repeat(50);
308 let result = sanitize_username(&long);
309 assert!(result.len() <= 30);
310 }
311
312 #[test]
313 fn provider_urls_match_endpoint_constants() {
314 assert_eq!(
316 SocialProvider::GitHub.authorize_url(),
317 endpoints::GITHUB_AUTHORIZE_URL
318 );
319 assert_eq!(
320 SocialProvider::GitHub.token_url(),
321 endpoints::GITHUB_TOKEN_URL
322 );
323 assert_eq!(
324 SocialProvider::GitHub.user_info_url(),
325 endpoints::GITHUB_USER_INFO_URL
326 );
327 assert_eq!(
328 SocialProvider::Google.authorize_url(),
329 endpoints::GOOGLE_AUTHORIZE_URL
330 );
331 assert_eq!(
332 SocialProvider::Google.token_url(),
333 endpoints::GOOGLE_TOKEN_URL
334 );
335 assert_eq!(
336 SocialProvider::Google.user_info_url(),
337 endpoints::GOOGLE_USER_INFO_URL
338 );
339 }
340}