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)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn build_authorize_url_github() {
262 let config = SocialProviderConfig {
263 client_id: "id123".into(),
264 client_secret: "secret".into(),
265 redirect_uri: "https://app.com/callback".into(),
266 };
267 let url = build_authorize_url(SocialProvider::GitHub, &config, "state123");
268 assert!(url.contains("client_id=id123"));
269 assert!(url.contains("state=state123"));
270 assert!(url.contains("github.com/login/oauth/authorize"));
271 }
272
273 #[test]
274 fn build_authorize_url_google() {
275 let config = SocialProviderConfig {
276 client_id: "id456".into(),
277 client_secret: "secret".into(),
278 redirect_uri: "https://app.com/callback".into(),
279 };
280 let url = build_authorize_url(SocialProvider::Google, &config, "state456");
281 assert!(url.contains("accounts.google.com"));
282 assert!(url.contains("scope=openid"));
283 }
284
285 #[test]
286 fn sanitize_username_normal() {
287 assert_eq!(sanitize_username("john_doe"), "john_doe");
288 }
289
290 #[test]
291 fn sanitize_username_special_chars() {
292 let result = sanitize_username("!@#");
293 assert!(result.starts_with("user_"));
294 assert!(result.len() > 5);
295 }
296
297 #[test]
298 fn sanitize_username_too_short() {
299 let result = sanitize_username("ab");
300 assert!(result.starts_with("user_"));
301 }
302
303 #[test]
304 fn sanitize_username_long() {
305 let long = "a".repeat(50);
306 let result = sanitize_username(&long);
307 assert!(result.len() <= 30);
308 }
309
310 #[test]
311 fn provider_urls_match_endpoint_constants() {
312 assert_eq!(
314 SocialProvider::GitHub.authorize_url(),
315 endpoints::GITHUB_AUTHORIZE_URL
316 );
317 assert_eq!(
318 SocialProvider::GitHub.token_url(),
319 endpoints::GITHUB_TOKEN_URL
320 );
321 assert_eq!(
322 SocialProvider::GitHub.user_info_url(),
323 endpoints::GITHUB_USER_INFO_URL
324 );
325 assert_eq!(
326 SocialProvider::Google.authorize_url(),
327 endpoints::GOOGLE_AUTHORIZE_URL
328 );
329 assert_eq!(
330 SocialProvider::Google.token_url(),
331 endpoints::GOOGLE_TOKEN_URL
332 );
333 assert_eq!(
334 SocialProvider::Google.user_info_url(),
335 endpoints::GOOGLE_USER_INFO_URL
336 );
337 }
338}