Skip to main content

allowthem_core/
social_github.rs

1//! GitHub `SocialProvider` implementation.
2//!
3//! ## Base URL injection for tests
4//!
5//! Two base URLs are hardcoded in the public `new()` constructor:
6//! - `oauth_base_url` → `https://github.com/login/oauth` (token exchange)
7//! - `api_base_url`   → `https://api.github.com` (user + emails lookup)
8//!
9//! Tests that need to point at a wiremock server use the crate-private
10//! `new_with_base_urls()` constructor instead. This avoids the `OnceLock`
11//! override pattern which is parallel-test-hostile.
12
13use serde::Deserialize;
14use url::Url;
15
16use crate::auth_client::AuthFuture;
17use crate::error::AuthError;
18use crate::social_providers::{ProviderType, SocialProvider, SocialProviderConfig, SocialUserInfo};
19
20// ── Struct ────────────────────────────────────────────────────────────────────
21
22/// GitHub OAuth 2.0 social provider.
23///
24/// Constructed from a [`SocialProviderConfig`] via [`Self::new`].
25/// `fetch_user_info` always calls both `/user` and `/user/emails`; the
26/// latter provides the verified primary email (per plan §3.2).
27#[derive(Debug)]
28pub struct GitHubSocialProvider {
29    client_id: String,
30    client_secret: String,
31    scopes: Vec<String>,
32    http: reqwest::Client,
33    /// Base URL for OAuth endpoints: `{oauth_base_url}/access_token` etc.
34    /// Always `https://github.com/login/oauth` in production.
35    oauth_base_url: String,
36    /// Base URL for API endpoints: `{api_base_url}/user` etc.
37    /// Always `https://api.github.com` in production.
38    api_base_url: String,
39}
40
41// ── Private response structs ──────────────────────────────────────────────────
42
43#[derive(Deserialize)]
44struct GitHubTokenResponse {
45    access_token: Option<String>,
46    error: Option<String>,
47    error_description: Option<String>,
48}
49
50#[derive(Deserialize)]
51struct GitHubUser {
52    id: i64,
53    name: Option<String>,
54    avatar_url: Option<String>,
55}
56
57#[derive(Deserialize)]
58struct GitHubEmail {
59    email: String,
60    primary: bool,
61    verified: bool,
62}
63
64// ── Constructors ──────────────────────────────────────────────────────────────
65
66impl GitHubSocialProvider {
67    /// Build a `GitHubSocialProvider` from a decrypted config.
68    ///
69    /// Returns `AuthError::Validation` if `provider_type` is not `Github`
70    /// or `scopes` is empty.
71    pub fn new(config: SocialProviderConfig) -> Result<Self, AuthError> {
72        Self::new_with_base_urls(
73            config,
74            "https://github.com/login/oauth".into(),
75            "https://api.github.com".into(),
76        )
77    }
78
79    /// Like [`Self::new`] but with overrideable base URLs.
80    ///
81    /// Used by tests to point at a wiremock server.
82    pub(crate) fn new_with_base_urls(
83        config: SocialProviderConfig,
84        oauth_base_url: String,
85        api_base_url: String,
86    ) -> Result<Self, AuthError> {
87        if config.provider_type != ProviderType::Github {
88            return Err(AuthError::Validation(
89                "provider_type mismatch: expected Github".into(),
90            ));
91        }
92        if config.scopes.is_empty() {
93            return Err(AuthError::Validation("scopes must not be empty".into()));
94        }
95        let http = reqwest::Client::builder()
96            .user_agent("allowthem-oauth")
97            .build()
98            .map_err(|e| AuthError::Validation(format!("reqwest client build failed: {e}")))?;
99        Ok(Self {
100            client_id: config.client_id,
101            client_secret: config.client_secret,
102            scopes: config.scopes,
103            http,
104            oauth_base_url,
105            api_base_url,
106        })
107    }
108}
109
110// ── SocialProvider impl ───────────────────────────────────────────────────────
111
112impl SocialProvider for GitHubSocialProvider {
113    fn provider_type(&self) -> ProviderType {
114        ProviderType::Github
115    }
116
117    fn authorize_url(&self, redirect_uri: &str, state: &str, pkce_challenge: &str) -> String {
118        let mut url = Url::parse("https://github.com/login/oauth/authorize").expect("static URL");
119        url.query_pairs_mut()
120            .append_pair("client_id", &self.client_id)
121            .append_pair("redirect_uri", redirect_uri)
122            .append_pair("state", state)
123            .append_pair("scope", &self.scopes.join(" "))
124            .append_pair("code_challenge", pkce_challenge)
125            .append_pair("code_challenge_method", "S256");
126        url.into()
127    }
128
129    fn exchange_code<'a>(
130        &'a self,
131        code: &'a str,
132        redirect_uri: &'a str,
133        pkce_verifier: &'a str,
134    ) -> AuthFuture<'a, String> {
135        Box::pin(async move {
136            let token_url = format!("{}/access_token", self.oauth_base_url);
137            let resp = self
138                .http
139                .post(&token_url)
140                .header("Accept", "application/json")
141                .form(&[
142                    ("client_id", self.client_id.as_str()),
143                    ("client_secret", self.client_secret.as_str()),
144                    ("code", code),
145                    ("redirect_uri", redirect_uri),
146                    ("code_verifier", pkce_verifier),
147                ])
148                .send()
149                .await
150                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?;
151
152            let token_resp: GitHubTokenResponse = resp
153                .json()
154                .await
155                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?;
156
157            if let Some(err) = token_resp.error {
158                let desc = token_resp.error_description.unwrap_or_default();
159                return Err(AuthError::OAuthTokenExchange(format!("{err}: {desc}")));
160            }
161
162            token_resp
163                .access_token
164                .ok_or_else(|| AuthError::OAuthTokenExchange("missing access_token".into()))
165        })
166    }
167
168    fn fetch_user_info<'a>(&'a self, access_token: &'a str) -> AuthFuture<'a, SocialUserInfo> {
169        Box::pin(async move {
170            // Step 1: GET /user for profile fields (id, name, avatar_url).
171            // Do not read the `email` field — always use /user/emails instead.
172            let user_url = format!("{}/user", self.api_base_url);
173            let user: GitHubUser = self
174                .http
175                .get(&user_url)
176                .bearer_auth(access_token)
177                .send()
178                .await
179                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?
180                .json()
181                .await
182                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?;
183
184            // Step 2: GET /user/emails for verified primary email.
185            let emails_url = format!("{}/user/emails", self.api_base_url);
186            let emails: Vec<GitHubEmail> = self
187                .http
188                .get(&emails_url)
189                .bearer_auth(access_token)
190                .send()
191                .await
192                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?
193                .json()
194                .await
195                .map_err(|e| AuthError::OAuthHttp(format!("{e}")))?;
196
197            let primary_email = emails
198                .into_iter()
199                .find(|e| e.primary && e.verified)
200                .ok_or_else(|| {
201                    AuthError::OAuthUserInfoFetch(
202                        "no verified primary email on GitHub account".into(),
203                    )
204                })?;
205
206            Ok(SocialUserInfo {
207                provider_user_id: user.id.to_string(),
208                email: primary_email.email,
209                email_verified: true,
210                name: user.name,
211                avatar_url: user.avatar_url,
212            })
213        })
214    }
215}
216
217// ── Tests ─────────────────────────────────────────────────────────────────────
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::types::SocialProviderId;
223
224    fn github_config() -> SocialProviderConfig {
225        SocialProviderConfig {
226            id: SocialProviderId::new(),
227            provider_type: ProviderType::Github,
228            display_name: "GitHub".into(),
229            client_id: "test-client-id".into(),
230            client_secret: "test-client-secret".into(),
231            scopes: vec!["user:email".into(), "read:user".into()],
232            enabled: true,
233            priority: 0,
234            config: None,
235        }
236    }
237
238    // ── Constructor validation ────────────────────────────────────────────────
239
240    #[test]
241    fn new_rejects_provider_type_mismatch() {
242        let mut cfg = github_config();
243        cfg.provider_type = ProviderType::Google;
244        let err = GitHubSocialProvider::new(cfg).unwrap_err();
245        assert!(matches!(err, AuthError::Validation(_)));
246    }
247
248    #[test]
249    fn new_rejects_empty_scopes() {
250        let mut cfg = github_config();
251        cfg.scopes = vec![];
252        let err = GitHubSocialProvider::new(cfg).unwrap_err();
253        assert!(matches!(err, AuthError::Validation(_)));
254    }
255
256    // ── authorize_url ─────────────────────────────────────────────────────────
257
258    #[test]
259    fn authorize_url_contains_required_params() {
260        let provider = GitHubSocialProvider::new(github_config()).unwrap();
261        let url = provider.authorize_url("https://example.com/callback", "mystate", "mychallenge");
262        assert!(url.contains("client_id=test-client-id"), "url: {url}");
263        assert!(url.contains("redirect_uri="), "url: {url}");
264        assert!(url.contains("state=mystate"), "url: {url}");
265        assert!(url.contains("code_challenge=mychallenge"), "url: {url}");
266        assert!(url.contains("code_challenge_method=S256"), "url: {url}");
267    }
268
269    #[test]
270    fn authorize_url_uses_config_scopes_joined_by_space() {
271        let provider = GitHubSocialProvider::new(github_config()).unwrap();
272        let url = provider.authorize_url("https://example.com/callback", "s", "c");
273        assert!(
274            url.contains("scope=user%3Aemail") || url.contains("scope=user:email"),
275            "url: {url}"
276        );
277    }
278
279    #[test]
280    fn authorize_url_does_not_leak_client_secret() {
281        let provider = GitHubSocialProvider::new(github_config()).unwrap();
282        let url = provider.authorize_url("https://example.com/callback", "s", "c");
283        assert!(!url.contains("test-client-secret"), "url: {url}");
284    }
285
286    // ── HTTP tests (wiremock) ─────────────────────────────────────────────────
287
288    async fn setup_server() -> (wiremock::MockServer, String, String) {
289        let server = wiremock::MockServer::start().await;
290        let oauth_url = server.uri();
291        let api_url = server.uri();
292        (server, oauth_url, api_url)
293    }
294
295    #[tokio::test]
296    async fn exchange_code_extracts_access_token_on_success() {
297        use wiremock::matchers::{method, path};
298        use wiremock::{Mock, ResponseTemplate};
299
300        let (server, oauth_url, api_url) = setup_server().await;
301        Mock::given(method("POST"))
302            .and(path("/access_token"))
303            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
304                "access_token": "ghs_test_token",
305                "token_type": "bearer",
306                "scope": "user:email"
307            })))
308            .mount(&server)
309            .await;
310
311        let provider =
312            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
313        let token = provider
314            .exchange_code("mycode", "https://example.com/cb", "v")
315            .await
316            .unwrap();
317        assert_eq!(token, "ghs_test_token");
318    }
319
320    #[tokio::test]
321    async fn exchange_code_returns_error_when_github_returns_error_field() {
322        use wiremock::matchers::{method, path};
323        use wiremock::{Mock, ResponseTemplate};
324
325        let (server, oauth_url, api_url) = setup_server().await;
326        Mock::given(method("POST"))
327            .and(path("/access_token"))
328            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
329                "error": "bad_verification_code",
330                "error_description": "The code passed is incorrect or expired."
331            })))
332            .mount(&server)
333            .await;
334
335        let provider =
336            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
337        let err = provider
338            .exchange_code("badcode", "https://example.com/cb", "v")
339            .await
340            .unwrap_err();
341        assert!(matches!(err, AuthError::OAuthTokenExchange(_)));
342    }
343
344    #[tokio::test]
345    async fn exchange_code_returns_error_when_access_token_missing() {
346        use wiremock::matchers::{method, path};
347        use wiremock::{Mock, ResponseTemplate};
348
349        let (server, oauth_url, api_url) = setup_server().await;
350        Mock::given(method("POST"))
351            .and(path("/access_token"))
352            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
353                "token_type": "bearer"
354            })))
355            .mount(&server)
356            .await;
357
358        let provider =
359            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
360        let err = provider
361            .exchange_code("code", "https://example.com/cb", "v")
362            .await
363            .unwrap_err();
364        assert!(matches!(err, AuthError::OAuthTokenExchange(_)));
365    }
366
367    #[tokio::test]
368    async fn fetch_user_info_combines_user_and_emails_endpoint() {
369        use wiremock::matchers::{method, path};
370        use wiremock::{Mock, ResponseTemplate};
371
372        let (server, oauth_url, api_url) = setup_server().await;
373        Mock::given(method("GET"))
374            .and(path("/user"))
375            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
376                "id": 42,
377                "name": "Test User",
378                "avatar_url": "https://avatars.example.com/u/42"
379            })))
380            .mount(&server)
381            .await;
382        Mock::given(method("GET"))
383            .and(path("/user/emails"))
384            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
385                {"email": "test@example.com", "primary": true, "verified": true}
386            ])))
387            .mount(&server)
388            .await;
389
390        let provider =
391            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
392        let info = provider.fetch_user_info("token123").await.unwrap();
393        assert_eq!(info.provider_user_id, "42");
394        assert_eq!(info.email, "test@example.com");
395        assert!(info.email_verified);
396        assert_eq!(info.name.as_deref(), Some("Test User"));
397        assert_eq!(
398            info.avatar_url.as_deref(),
399            Some("https://avatars.example.com/u/42")
400        );
401    }
402
403    #[tokio::test]
404    async fn fetch_user_info_picks_primary_verified_email_when_multiple() {
405        use wiremock::matchers::{method, path};
406        use wiremock::{Mock, ResponseTemplate};
407
408        let (server, oauth_url, api_url) = setup_server().await;
409        Mock::given(method("GET"))
410            .and(path("/user"))
411            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
412                "id": 7,
413                "name": null,
414                "avatar_url": null
415            })))
416            .mount(&server)
417            .await;
418        Mock::given(method("GET"))
419            .and(path("/user/emails"))
420            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
421                {"email": "secondary@example.com", "primary": false, "verified": true},
422                {"email": "primary@example.com",   "primary": true,  "verified": true},
423                {"email": "unverified@example.com","primary": false, "verified": false}
424            ])))
425            .mount(&server)
426            .await;
427
428        let provider =
429            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
430        let info = provider.fetch_user_info("tok").await.unwrap();
431        assert_eq!(info.email, "primary@example.com");
432    }
433
434    #[tokio::test]
435    async fn fetch_user_info_errors_when_no_primary_verified_email() {
436        use wiremock::matchers::{method, path};
437        use wiremock::{Mock, ResponseTemplate};
438
439        let (server, oauth_url, api_url) = setup_server().await;
440        Mock::given(method("GET"))
441            .and(path("/user"))
442            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
443                "id": 5, "name": null, "avatar_url": null
444            })))
445            .mount(&server)
446            .await;
447        Mock::given(method("GET"))
448            .and(path("/user/emails"))
449            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
450                {"email": "nope@example.com", "primary": true, "verified": false}
451            ])))
452            .mount(&server)
453            .await;
454
455        let provider =
456            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
457        let err = provider.fetch_user_info("tok").await.unwrap_err();
458        assert!(matches!(err, AuthError::OAuthUserInfoFetch(_)));
459    }
460
461    #[tokio::test]
462    async fn fetch_user_info_propagates_avatar_url() {
463        use wiremock::matchers::{method, path};
464        use wiremock::{Mock, ResponseTemplate};
465
466        let (server, oauth_url, api_url) = setup_server().await;
467        Mock::given(method("GET"))
468            .and(path("/user"))
469            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
470                "id": 99,
471                "name": "Avatar User",
472                "avatar_url": "https://cdn.example.com/avatar99.png"
473            })))
474            .mount(&server)
475            .await;
476        Mock::given(method("GET"))
477            .and(path("/user/emails"))
478            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
479                {"email": "avatar@example.com", "primary": true, "verified": true}
480            ])))
481            .mount(&server)
482            .await;
483
484        let provider =
485            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
486        let info = provider.fetch_user_info("tok").await.unwrap();
487        assert_eq!(
488            info.avatar_url.as_deref(),
489            Some("https://cdn.example.com/avatar99.png")
490        );
491    }
492
493    #[tokio::test]
494    async fn fetch_user_info_does_not_use_user_email_field() {
495        use wiremock::matchers::{method, path};
496        use wiremock::{Mock, ResponseTemplate};
497
498        // /user returns a public email; /user/emails has a *different* primary email.
499        // The impl must use the /user/emails result.
500        let (server, oauth_url, api_url) = setup_server().await;
501        Mock::given(method("GET"))
502            .and(path("/user"))
503            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
504                "id": 11,
505                "name": "Split User",
506                "avatar_url": null,
507                "email": "public@example.com"
508            })))
509            .mount(&server)
510            .await;
511        Mock::given(method("GET"))
512            .and(path("/user/emails"))
513            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
514                {"email": "private@example.com", "primary": true, "verified": true}
515            ])))
516            .mount(&server)
517            .await;
518
519        let provider =
520            GitHubSocialProvider::new_with_base_urls(github_config(), oauth_url, api_url).unwrap();
521        let info = provider.fetch_user_info("tok").await.unwrap();
522        assert_eq!(
523            info.email, "private@example.com",
524            "must use /user/emails, not /user.email"
525        );
526    }
527}