Skip to main content

oauth_toolkit/
social.rs

1//! Social login providers (GitHub, Google).
2
3use serde::{Deserialize, Serialize};
4
5/// Supported social login providers.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub enum SocialProvider {
8    /// GitHub OAuth2.
9    GitHub,
10    /// Google OAuth2.
11    Google,
12}
13
14impl SocialProvider {
15    /// Authorization URL for the provider.
16    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    /// Token exchange URL for the provider.
24    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    /// Default scopes for the provider.
32    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    /// User info URL for the provider.
40    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/// Configuration for a social login provider.
49#[derive(Debug, Clone)]
50pub struct SocialProviderConfig {
51    /// OAuth2 client ID.
52    pub client_id: String,
53    /// OAuth2 client secret.
54    pub client_secret: String,
55    /// Redirect URI after authorization.
56    pub redirect_uri: String,
57}
58
59/// User information returned by a social login provider.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SocialUserInfo {
62    /// Provider name (e.g., "github", "google").
63    pub provider: String,
64    /// Provider-specific user ID.
65    pub provider_user_id: String,
66    /// User email address.
67    pub email: String,
68    /// User display name.
69    pub name: String,
70    /// User avatar URL (optional).
71    pub avatar_url: Option<String>,
72}
73
74/// Errors for social login operations.
75#[derive(Debug, thiserror::Error)]
76pub enum SocialError {
77    /// Token exchange with the provider failed.
78    #[error("token exchange failed: {0}")]
79    TokenExchangeFailed(String),
80
81    /// User info fetch from the provider failed.
82    #[error("user info fetch failed: {0}")]
83    UserInfoFailed(String),
84
85    /// A required field is missing from the provider response.
86    #[error("missing required field: {0}")]
87    MissingField(String),
88
89    /// HTTP request error.
90    #[error("HTTP error: {0}")]
91    HttpError(String),
92}
93
94/// Build the authorization redirect URL with CSRF state.
95pub 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
113/// Exchange an authorization code for an access token.
114pub 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
173/// Fetch user profile info from the provider.
174pub 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
239/// Sanitize a username from a provider: alphanumeric + underscore, 3-30 chars.
240/// Falls back to `user_{random}` if too short after sanitization.
241pub 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}