1use 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#[derive(Debug)]
28pub struct GitHubSocialProvider {
29 client_id: String,
30 client_secret: String,
31 scopes: Vec<String>,
32 http: reqwest::Client,
33 oauth_base_url: String,
36 api_base_url: String,
39}
40
41#[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
64impl GitHubSocialProvider {
67 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 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
110impl 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 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 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#[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 #[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 #[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 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 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}