aster_forge_external_auth/providers/
github.rs1use 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#[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 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}