1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub enum SocialProvider {
8 GitHub,
10 Google,
12}
13
14impl SocialProvider {
15 pub fn authorize_url(&self) -> &'static str {
17 match self {
18 Self::GitHub => "https://github.com/login/oauth/authorize",
19 Self::Google => "https://accounts.google.com/o/oauth2/v2/auth",
20 }
21 }
22
23 pub fn token_url(&self) -> &'static str {
25 match self {
26 Self::GitHub => "https://github.com/login/oauth/access_token",
27 Self::Google => "https://oauth2.googleapis.com/token",
28 }
29 }
30
31 pub fn default_scopes(&self) -> &'static [&'static str] {
33 match self {
34 Self::GitHub => &["read:user", "user:email"],
35 Self::Google => &["openid", "email", "profile"],
36 }
37 }
38
39 pub fn user_info_url(&self) -> &'static str {
41 match self {
42 Self::GitHub => "https://api.github.com/user",
43 Self::Google => "https://www.googleapis.com/oauth2/v2/userinfo",
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct SocialProviderConfig {
51 pub client_id: String,
53 pub client_secret: String,
55 pub redirect_uri: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SocialUserInfo {
62 pub provider: String,
64 pub provider_user_id: String,
66 pub email: String,
68 pub name: String,
70 pub avatar_url: Option<String>,
72}
73
74#[derive(Debug, thiserror::Error)]
76pub enum SocialError {
77 #[error("token exchange failed: {0}")]
79 TokenExchangeFailed(String),
80
81 #[error("user info fetch failed: {0}")]
83 UserInfoFailed(String),
84
85 #[error("missing required field: {0}")]
87 MissingField(String),
88
89 #[error("HTTP error: {0}")]
91 HttpError(String),
92}
93
94pub fn build_authorize_url(
96 provider: SocialProvider,
97 config: &SocialProviderConfig,
98 state: &str,
99) -> String {
100 let scopes = provider.default_scopes().join(" ");
101 let redirect = urlencoding::encode(&config.redirect_uri);
102 let state = urlencoding::encode(state);
103 format!(
104 "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code",
105 provider.authorize_url(),
106 config.client_id,
107 redirect,
108 scopes,
109 state,
110 )
111}
112
113pub async fn exchange_code(
115 client: &reqwest::Client,
116 provider: SocialProvider,
117 config: &SocialProviderConfig,
118 code: &str,
119) -> Result<String, SocialError> {
120 match provider {
121 SocialProvider::GitHub => {
122 let resp = client
123 .post(provider.token_url())
124 .header("Accept", "application/json")
125 .header("User-Agent", "oauth-toolkit")
126 .form(&[
127 ("client_id", config.client_id.as_str()),
128 ("client_secret", config.client_secret.as_str()),
129 ("code", code),
130 ("redirect_uri", config.redirect_uri.as_str()),
131 ])
132 .send()
133 .await
134 .map_err(|e| SocialError::HttpError(e.to_string()))?;
135
136 let body: serde_json::Value = resp
137 .json()
138 .await
139 .map_err(|e| SocialError::TokenExchangeFailed(e.to_string()))?;
140
141 body["access_token"]
142 .as_str()
143 .map(String::from)
144 .ok_or_else(|| SocialError::TokenExchangeFailed("no access_token in response".into()))
145 }
146 SocialProvider::Google => {
147 let resp = client
148 .post(provider.token_url())
149 .form(&[
150 ("client_id", config.client_id.as_str()),
151 ("client_secret", config.client_secret.as_str()),
152 ("code", code),
153 ("redirect_uri", config.redirect_uri.as_str()),
154 ("grant_type", "authorization_code"),
155 ])
156 .send()
157 .await
158 .map_err(|e| SocialError::HttpError(e.to_string()))?;
159
160 let body: serde_json::Value = resp
161 .json()
162 .await
163 .map_err(|e| SocialError::TokenExchangeFailed(e.to_string()))?;
164
165 body["access_token"]
166 .as_str()
167 .map(String::from)
168 .ok_or_else(|| SocialError::TokenExchangeFailed("no access_token in response".into()))
169 }
170 }
171}
172
173pub async fn fetch_user_info(
175 client: &reqwest::Client,
176 provider: SocialProvider,
177 access_token: &str,
178) -> Result<SocialUserInfo, SocialError> {
179 let resp = client
180 .get(provider.user_info_url())
181 .bearer_auth(access_token)
182 .send()
183 .await
184 .map_err(|e| SocialError::HttpError(e.to_string()))?;
185
186 let body: serde_json::Value = resp
187 .json()
188 .await
189 .map_err(|e| SocialError::UserInfoFailed(e.to_string()))?;
190
191 match provider {
192 SocialProvider::GitHub => {
193 let user_id = body["id"]
194 .as_str()
195 .or_else(|| body["id"].as_i64().map(|_| ""))
196 .ok_or(SocialError::MissingField("id".into()))?;
197 let email = body["email"]
198 .as_str()
199 .unwrap_or("")
200 .to_string();
201 let name = body["name"]
202 .as_str()
203 .unwrap_or("")
204 .to_string();
205 let avatar = body["avatar_url"].as_str().map(String::from);
206
207 Ok(SocialUserInfo {
208 provider: "github".into(),
209 provider_user_id: user_id.to_string(),
210 email,
211 name,
212 avatar_url: avatar,
213 })
214 }
215 SocialProvider::Google => {
216 let user_id = body["id"]
217 .as_str()
218 .ok_or(SocialError::MissingField("id".into()))?;
219 let email = body["email"]
220 .as_str()
221 .ok_or(SocialError::MissingField("email".into()))?;
222 let name = body["name"]
223 .as_str()
224 .unwrap_or("")
225 .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}