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 #[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}