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