Skip to main content

oauth_toolkit/
social.rs

1//! Social login providers (GitHub, Google).
2//!
3//! Endpoint URLs come from [`crate::providers::endpoints`] — the single
4//! source of truth shared with the mail provider presets.
5
6use serde::{Deserialize, Serialize};
7
8use crate::providers::endpoints;
9
10/// Supported social login providers.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum SocialProvider {
13    /// GitHub OAuth2.
14    GitHub,
15    /// Google OAuth2.
16    Google,
17}
18
19impl SocialProvider {
20    /// Authorization URL for the provider.
21    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    /// Token exchange URL for the provider.
29    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    /// Default scopes for the provider.
37    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    /// User info URL for the provider.
45    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/// Configuration for a social login provider.
54#[derive(Debug, Clone)]
55pub struct SocialProviderConfig {
56    /// OAuth2 client ID.
57    pub client_id: String,
58    /// OAuth2 client secret.
59    pub client_secret: String,
60    /// Redirect URI after authorization.
61    pub redirect_uri: String,
62}
63
64/// User information returned by a social login provider.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SocialUserInfo {
67    /// Provider name (e.g., "github", "google").
68    pub provider: String,
69    /// Provider-specific user ID.
70    pub provider_user_id: String,
71    /// User email address.
72    pub email: String,
73    /// User display name.
74    pub name: String,
75    /// User avatar URL (optional).
76    pub avatar_url: Option<String>,
77}
78
79/// Errors for social login operations.
80#[derive(Debug, thiserror::Error)]
81pub enum SocialError {
82    /// Token exchange with the provider failed.
83    #[error("token exchange failed: {0}")]
84    TokenExchangeFailed(String),
85
86    /// User info fetch from the provider failed.
87    #[error("user info fetch failed: {0}")]
88    UserInfoFailed(String),
89
90    /// A required field is missing from the provider response.
91    #[error("missing required field: {0}")]
92    MissingField(String),
93
94    /// HTTP request error.
95    #[error("HTTP error: {0}")]
96    HttpError(String),
97}
98
99/// Build the authorization redirect URL with CSRF state.
100pub 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
118/// Exchange an authorization code for an access token.
119pub 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
182/// Fetch user profile info from the provider.
183pub 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
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)]
257// Test code: unwrap/expect are the idiomatic way to assert setup success.
258#[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        // Dedupe regression: social URLs must come from providers::endpoints.
315        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}