Skip to main content

aster_forge_external_auth/providers/
github.rs

1//! GitHub OAuth App provider driver.
2//!
3//! GitHub sign-in mostly follows OAuth2 authorization code flow, but email verification requires a
4//! second call to the user emails API. This driver applies GitHub's fixed endpoints and maps the
5//! verified primary email into the normalized external-auth profile.
6
7use async_trait::async_trait;
8use reqwest::header;
9use serde::Deserialize;
10
11use crate::driver::{
12    ExternalAuthAuthorizationStart, ExternalAuthCallback, ExternalAuthProfile,
13    ExternalAuthProviderConfig, ExternalAuthProviderDescriptor, ExternalAuthProviderDriver,
14    ExternalAuthProviderTestCheck, ExternalAuthProviderTestResult,
15};
16use crate::types::{ExternalAuthProtocol, ExternalAuthProviderKind};
17use crate::{ExternalAuthError, MapExternalAuthErr, Result};
18
19use super::oauth2::{
20    OAuth2ProviderDriver, exchange_code_for_token, fetch_userinfo, oauth2_endpoint_error,
21    oauth2_http_client, profile_from_userinfo, validate_url,
22};
23
24const GITHUB_AUTHORIZATION_URL: &str = "https://github.com/login/oauth/authorize";
25const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
26const GITHUB_USERINFO_URL: &str = "https://api.github.com/user";
27const GITHUB_DEFAULT_SCOPES: &str = "read:user user:email";
28const GITHUB_EMAIL_IGNORED_CLAIM: &str = "__asterdrive_github_email_ignored__";
29const GITHUB_EMAIL_VERIFIED_IGNORED_CLAIM: &str = "__asterdrive_github_email_verified_ignored__";
30
31/// GitHub OAuth App provider driver.
32#[derive(Default)]
33pub struct GitHubProviderDriver;
34
35#[derive(Debug, Deserialize)]
36struct GitHubEmail {
37    email: String,
38    #[serde(default)]
39    primary: bool,
40    #[serde(default)]
41    verified: bool,
42}
43
44impl GitHubProviderDriver {
45    /// Creates a GitHub provider driver with fixed GitHub OAuth endpoints.
46    pub fn new() -> Self {
47        Self
48    }
49}
50
51#[async_trait]
52impl ExternalAuthProviderDriver for GitHubProviderDriver {
53    fn kind(&self) -> ExternalAuthProviderKind {
54        ExternalAuthProviderKind::GitHub
55    }
56
57    fn descriptor(&self) -> ExternalAuthProviderDescriptor {
58        ExternalAuthProviderDescriptor {
59            kind: ExternalAuthProviderKind::GitHub,
60            protocol: ExternalAuthProtocol::OAuth2,
61            display_name: "GitHub",
62            description: "GitHub OAuth App sign-in with verified primary email fetched from the user emails API.",
63            default_scopes: GITHUB_DEFAULT_SCOPES,
64            issuer_url_required: false,
65            manual_endpoint_configuration_supported: false,
66            authorization_url_required: false,
67            token_url_required: false,
68            userinfo_url_required: false,
69            supports_discovery: false,
70            supports_pkce: true,
71            supports_email_verified_claim: false,
72        }
73    }
74
75    async fn start_authorization(
76        &self,
77        provider: &ExternalAuthProviderConfig,
78        redirect_uri: &str,
79    ) -> Result<ExternalAuthAuthorizationStart> {
80        OAuth2ProviderDriver::new()
81            .start_authorization(&github_oauth2_config(provider), redirect_uri)
82            .await
83    }
84
85    async fn exchange_callback(
86        &self,
87        provider: &ExternalAuthProviderConfig,
88        callback: ExternalAuthCallback,
89    ) -> Result<ExternalAuthProfile> {
90        let pkce_verifier = callback.pkce_verifier.ok_or_else(|| {
91            ExternalAuthError::database_operation("stored GitHub OAuth2 PKCE verifier is missing")
92        })?;
93        let provider = github_oauth2_config(provider);
94        let http_client = oauth2_http_client(&provider)?;
95        let token = exchange_code_for_token(
96            &http_client,
97            &provider,
98            &callback.code,
99            &callback.redirect_uri,
100            &pkce_verifier,
101        )
102        .await?;
103        let userinfo = fetch_userinfo(&http_client, &provider, &token).await?;
104        let mut profile = profile_from_userinfo(&provider, &userinfo)?;
105        profile.email = fetch_verified_primary_email(&http_client, &provider, &token).await?;
106        profile.email_verified = profile.email.is_some();
107        Ok(profile)
108    }
109
110    async fn test_provider(
111        &self,
112        provider: &ExternalAuthProviderConfig,
113    ) -> Result<ExternalAuthProviderTestResult> {
114        if provider.client_id.trim().is_empty() {
115            return Err(ExternalAuthError::validation_error("client_id is required"));
116        }
117        let provider = github_oauth2_config(provider);
118        let authorization_url = provider.authorization_url.as_deref().ok_or_else(|| {
119            ExternalAuthError::validation_error("GitHub authorization URL is missing")
120        })?;
121        let token_url = provider
122            .token_url
123            .as_deref()
124            .ok_or_else(|| ExternalAuthError::validation_error("GitHub token URL is missing"))?;
125        let userinfo_url = provider
126            .userinfo_url
127            .as_deref()
128            .ok_or_else(|| ExternalAuthError::validation_error("GitHub userinfo URL is missing"))?;
129        validate_url(
130            authorization_url,
131            "authorization_url",
132            ExternalAuthError::validation_error,
133        )?;
134        validate_url(token_url, "token_url", ExternalAuthError::validation_error)?;
135        validate_url(
136            userinfo_url,
137            "userinfo_url",
138            ExternalAuthError::validation_error,
139        )?;
140
141        Ok(ExternalAuthProviderTestResult {
142            provider: self.descriptor().display_name.to_string(),
143            issuer: provider.issuer_url.clone(),
144            authorization_endpoint: Some(authorization_url.to_string()),
145            token_endpoint: Some(token_url.to_string()),
146            userinfo_endpoint: Some(userinfo_url.to_string()),
147            jwks_key_count: None,
148            checks: vec![
149                ExternalAuthProviderTestCheck {
150                    name: "github_endpoints".to_string(),
151                    success: true,
152                    message:
153                        "GitHub authorization, token, user and user emails endpoints are configured"
154                            .to_string(),
155                },
156                ExternalAuthProviderTestCheck {
157                    name: "verified_primary_email".to_string(),
158                    success: true,
159                    message:
160                        "GitHub verified primary email is read from /user/emails during sign-in"
161                            .to_string(),
162                },
163            ],
164        })
165    }
166}
167
168fn github_oauth2_config(provider: &ExternalAuthProviderConfig) -> ExternalAuthProviderConfig {
169    let mut provider = provider.clone();
170    provider.provider_kind = ExternalAuthProviderKind::GitHub;
171    provider.protocol = ExternalAuthProtocol::OAuth2;
172    provider.authorization_url = provider
173        .authorization_url
174        .filter(|value| !value.trim().is_empty())
175        .or_else(|| Some(GITHUB_AUTHORIZATION_URL.to_string()));
176    provider.token_url = provider
177        .token_url
178        .filter(|value| !value.trim().is_empty())
179        .or_else(|| Some(GITHUB_TOKEN_URL.to_string()));
180    provider.userinfo_url = provider
181        .userinfo_url
182        .filter(|value| !value.trim().is_empty())
183        .or_else(|| Some(GITHUB_USERINFO_URL.to_string()));
184    provider.scopes = if provider.scopes.trim().is_empty() {
185        GITHUB_DEFAULT_SCOPES.to_string()
186    } else {
187        provider.scopes.trim().to_string()
188    };
189    provider.subject_claim = provider.subject_claim.or_else(|| Some("id".to_string()));
190    provider.username_claim = provider
191        .username_claim
192        .or_else(|| Some("login".to_string()));
193    provider.display_name_claim = provider
194        .display_name_claim
195        .or_else(|| Some("name".to_string()));
196    provider.email_claim = Some(GITHUB_EMAIL_IGNORED_CLAIM.to_string());
197    provider.email_verified_claim = Some(GITHUB_EMAIL_VERIFIED_IGNORED_CLAIM.to_string());
198    provider
199}
200
201async fn fetch_verified_primary_email(
202    http_client: &reqwest::Client,
203    provider: &ExternalAuthProviderConfig,
204    access_token: &str,
205) -> Result<Option<String>> {
206    let emails_url = github_emails_url(provider)?;
207    let response = http_client
208        .get(&emails_url)
209        .bearer_auth(access_token)
210        .header(header::ACCEPT, "application/json")
211        .send()
212        .await
213        .map_external_auth_err_ctx(
214            "GitHub user emails request failed",
215            ExternalAuthError::auth_invalid_credentials,
216        )?;
217    if !response.status().is_success() {
218        return Err(oauth2_endpoint_error(response, "GitHub user emails request").await);
219    }
220
221    let emails = response
222        .json::<Vec<GitHubEmail>>()
223        .await
224        .map_external_auth_err_ctx(
225            "GitHub user emails response is invalid",
226            ExternalAuthError::auth_invalid_credentials,
227        )?;
228    select_verified_primary_email(emails)
229}
230
231fn github_emails_url(provider: &ExternalAuthProviderConfig) -> Result<String> {
232    let userinfo_url = provider
233        .userinfo_url
234        .as_deref()
235        .filter(|value| !value.trim().is_empty())
236        .unwrap_or(GITHUB_USERINFO_URL);
237    let mut parsed = validate_url(
238        userinfo_url,
239        "userinfo_url",
240        ExternalAuthError::config_error,
241    )?;
242    let path = parsed.path().trim_end_matches('/');
243    parsed.set_path(&format!("{path}/emails"));
244    parsed.set_query(None);
245    parsed.set_fragment(None);
246    Ok(parsed.to_string())
247}
248
249fn select_verified_primary_email(emails: Vec<GitHubEmail>) -> Result<Option<String>> {
250    let Some(email) = emails
251        .into_iter()
252        .find(|email| email.primary && email.verified)
253        .map(|email| email.email.trim().to_string())
254        .filter(|email| !email.is_empty())
255    else {
256        return Ok(None);
257    };
258    aster_forge_validation::email::validate_email(&email).map_err(|_| {
259        ExternalAuthError::auth_invalid_credentials("GitHub primary email is invalid")
260    })?;
261    Ok(Some(email))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn provider() -> ExternalAuthProviderConfig {
269        ExternalAuthProviderConfig {
270            id: 1,
271            key: "github".to_string(),
272            provider_kind: ExternalAuthProviderKind::GitHub,
273            protocol: ExternalAuthProtocol::OAuth2,
274            options: Default::default(),
275            issuer_url: None,
276            authorization_url: None,
277            token_url: None,
278            userinfo_url: None,
279            client_id: "client".to_string(),
280            client_secret: Some("secret".to_string()),
281            scopes: String::new(),
282            subject_claim: None,
283            username_claim: None,
284            display_name_claim: None,
285            email_claim: None,
286            email_verified_claim: None,
287            groups_claim: None,
288            avatar_url_claim: None,
289            outbound_http_user_agent: None,
290        }
291    }
292
293    #[test]
294    fn github_config_uses_fixed_defaults_and_claims() {
295        let config = github_oauth2_config(&provider());
296
297        assert_eq!(
298            config.authorization_url.as_deref(),
299            Some(GITHUB_AUTHORIZATION_URL)
300        );
301        assert_eq!(config.token_url.as_deref(), Some(GITHUB_TOKEN_URL));
302        assert_eq!(config.userinfo_url.as_deref(), Some(GITHUB_USERINFO_URL));
303        assert_eq!(config.scopes, GITHUB_DEFAULT_SCOPES);
304        assert_eq!(config.subject_claim.as_deref(), Some("id"));
305        assert_eq!(config.username_claim.as_deref(), Some("login"));
306        assert_eq!(config.display_name_claim.as_deref(), Some("name"));
307    }
308
309    #[test]
310    fn github_emails_url_is_derived_from_userinfo_url() {
311        let mut config = github_oauth2_config(&provider());
312        config.userinfo_url = Some("https://api.github.test/user?ignored=true".to_string());
313
314        let emails_url = github_emails_url(&config).expect("emails URL should build");
315
316        assert_eq!(emails_url, "https://api.github.test/user/emails");
317    }
318
319    #[test]
320    fn verified_primary_email_selection_requires_primary_and_verified() {
321        let selected = select_verified_primary_email(vec![
322            GitHubEmail {
323                email: "secondary@example.com".to_string(),
324                primary: false,
325                verified: true,
326            },
327            GitHubEmail {
328                email: "primary-unverified@example.com".to_string(),
329                primary: true,
330                verified: false,
331            },
332            GitHubEmail {
333                email: " github@example.com ".to_string(),
334                primary: true,
335                verified: true,
336            },
337        ])
338        .expect("email selection should succeed");
339
340        assert_eq!(selected.as_deref(), Some("github@example.com"));
341    }
342
343    #[test]
344    fn verified_primary_email_selection_returns_none_when_missing() {
345        let selected = select_verified_primary_email(vec![
346            GitHubEmail {
347                email: "secondary@example.com".to_string(),
348                primary: false,
349                verified: true,
350            },
351            GitHubEmail {
352                email: "primary-unverified@example.com".to_string(),
353                primary: true,
354                verified: false,
355            },
356        ])
357        .expect("missing verified primary email should not error");
358
359        assert_eq!(selected, None);
360    }
361
362    #[test]
363    fn verified_primary_email_selection_rejects_invalid_email() {
364        let error = select_verified_primary_email(vec![GitHubEmail {
365            email: "not-an-email".to_string(),
366            primary: true,
367            verified: true,
368        }])
369        .expect_err("invalid verified primary email should fail");
370
371        assert!(
372            error
373                .to_string()
374                .contains("GitHub primary email is invalid")
375        );
376    }
377}