1use std::time::Duration;
3
4use async_trait::async_trait;
5use serde::Deserialize;
6use tracing::warn;
7
8const GITHUB_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
10
11const MAX_GITHUB_RESPONSE_BYTES: usize = 5 * 1024 * 1024; use crate::{
19 error::{AuthError, Result},
20 oidc_provider::OidcProvider,
21 provider::{OAuthProvider, TokenResponse, UserInfo},
22};
23
24#[derive(Debug)]
28pub struct GitHubOAuth {
29 oidc: OidcProvider,
30}
31
32#[derive(Debug, Clone, Deserialize)]
34pub struct GitHubUser {
35 pub id: u64,
37 pub login: String,
39 pub email: Option<String>,
41 pub name: Option<String>,
43 pub avatar_url: Option<String>,
45 pub bio: Option<String>,
47 pub company: Option<String>,
49 pub location: Option<String>,
51 pub public_repos: u32,
53}
54
55#[derive(Debug, Clone, Deserialize)]
57pub struct GitHubTeam {
58 pub id: u64,
60 pub name: String,
62 pub slug: String,
64 pub organization: GitHubOrg,
66}
67
68#[derive(Debug, Clone, Deserialize)]
70pub struct GitHubOrg {
71 pub id: u64,
73 pub login: String,
75}
76
77impl GitHubOAuth {
78 pub async fn new(
89 client_id: String,
90 client_secret: String,
91 redirect_uri: String,
92 ) -> Result<Self> {
93 let oidc = OidcProvider::new(
94 "github",
95 "https://github.com",
96 &client_id,
97 &client_secret,
98 &redirect_uri,
99 )
100 .await?;
101
102 Ok(Self { oidc })
103 }
104
105 pub fn map_teams_to_roles(teams: Vec<String>) -> Vec<String> {
113 teams
114 .into_iter()
115 .filter_map(|team| {
116 let parts: Vec<&str> = team.split(':').collect();
117 if parts.len() == 2 {
118 match parts[1] {
119 "admin" | "administrators" | "admin-team" => Some("admin".to_string()),
120 "operator" | "operators" | "operator-team" | "maintainer"
121 | "maintainers" => Some("operator".to_string()),
122 "viewer" | "viewers" | "viewer-team" => Some("viewer".to_string()),
123 _ => None,
124 }
125 } else {
126 None
127 }
128 })
129 .collect()
130 }
131
132 pub async fn get_user_with_teams(
142 &self,
143 access_token: &str,
144 ) -> Result<(GitHubUser, Vec<String>)> {
145 let client = reqwest::Client::builder()
146 .timeout(GITHUB_REQUEST_TIMEOUT)
147 .build()
148 .unwrap_or_default();
149
150 let user_resp = client
152 .get("https://api.github.com/user")
153 .header("Authorization", format!("token {}", access_token))
154 .header("User-Agent", "FraiseQL")
155 .send()
156 .await
157 .map_err(|e| AuthError::OAuthError {
158 message: format!("Failed to fetch GitHub user: {}", e),
159 })?;
160 let user_status = user_resp.status();
161 let user_bytes = user_resp.bytes().await.map_err(|e| AuthError::OAuthError {
162 message: format!("Failed to read GitHub user response: {}", e),
163 })?;
164 if !user_status.is_success() {
165 return Err(AuthError::OAuthError {
166 message: format!("GitHub user API returned HTTP {user_status}"),
167 });
168 }
169 if user_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
170 return Err(AuthError::OAuthError {
171 message: format!("GitHub user response too large ({} bytes)", user_bytes.len()),
172 });
173 }
174 let user: GitHubUser =
175 serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
176 message: format!("Failed to parse GitHub user: {}", e),
177 })?;
178
179 let teams_resp = client
181 .get("https://api.github.com/user/teams")
182 .header("Authorization", format!("token {}", access_token))
183 .header("User-Agent", "FraiseQL")
184 .send()
185 .await
186 .map_err(|e| AuthError::OAuthError {
187 message: format!("Failed to fetch GitHub teams: {}", e),
188 })?;
189 let teams_status = teams_resp.status();
190 let teams_bytes = teams_resp.bytes().await.map_err(|e| AuthError::OAuthError {
191 message: format!("Failed to read GitHub teams response: {}", e),
192 })?;
193 let teams: Vec<GitHubTeam> = if !teams_status.is_success() {
194 warn!(status = %teams_status, "GitHub teams API returned non-success — treating as empty");
195 Vec::new()
196 } else if teams_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
197 warn!("GitHub teams response too large — treating as empty");
198 Vec::new()
199 } else {
200 serde_json::from_slice(&teams_bytes).unwrap_or_else(|e| {
201 warn!(error = %e, "Failed to parse GitHub teams response — treating as empty");
202 Vec::new()
203 })
204 };
205
206 let team_strings: Vec<String> =
207 teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
208
209 Ok((user, team_strings))
210 }
211
212 pub fn extract_org_id_from_teams(teams: &[(GitHubUser, Vec<String>)]) -> Option<String> {
217 teams
218 .first()
219 .and_then(|(_, team_strings)| team_strings.first())
220 .and_then(|team_str| team_str.split(':').next())
221 .map(|org| org.to_string())
222 }
223}
224
225#[async_trait]
229impl OAuthProvider for GitHubOAuth {
230 fn name(&self) -> &'static str {
231 "github"
232 }
233
234 fn authorization_url(&self, state: &str) -> String {
235 self.oidc.authorization_url(state)
236 }
237
238 async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
239 self.oidc.exchange_code(code).await
240 }
241
242 async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
243 let user_info = self.oidc.user_info(access_token).await?;
245
246 let client = reqwest::Client::builder()
248 .timeout(GITHUB_REQUEST_TIMEOUT)
249 .build()
250 .unwrap_or_default();
251 let user_resp = client
252 .get("https://api.github.com/user")
253 .header("Authorization", format!("token {}", access_token))
254 .header("User-Agent", "FraiseQL")
255 .send()
256 .await
257 .map_err(|e| AuthError::OAuthError {
258 message: format!("Failed to fetch GitHub user: {}", e),
259 })?;
260 let user_status = user_resp.status();
261 let user_bytes = user_resp.bytes().await.map_err(|e| AuthError::OAuthError {
262 message: format!("Failed to read GitHub user response: {}", e),
263 })?;
264 if !user_status.is_success() {
265 return Err(AuthError::OAuthError {
266 message: format!("GitHub user API returned HTTP {user_status}"),
267 });
268 }
269 if user_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
270 return Err(AuthError::OAuthError {
271 message: format!("GitHub user response too large ({} bytes)", user_bytes.len()),
272 });
273 }
274 let github_user: GitHubUser =
275 serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
276 message: format!("Failed to parse GitHub user: {}", e),
277 })?;
278
279 let teams_resp = client
281 .get("https://api.github.com/user/teams")
282 .header("Authorization", format!("token {}", access_token))
283 .header("User-Agent", "FraiseQL")
284 .send()
285 .await
286 .map_err(|e| AuthError::OAuthError {
287 message: format!("Failed to fetch GitHub teams: {}", e),
288 })?;
289 let teams_status = teams_resp.status();
290 let teams_bytes = teams_resp.bytes().await.map_err(|e| AuthError::OAuthError {
291 message: format!("Failed to read GitHub teams response: {}", e),
292 })?;
293 let teams: Vec<GitHubTeam> = if !teams_status.is_success() {
294 warn!(status = %teams_status, "GitHub teams API returned non-success — treating as empty");
295 Vec::new()
296 } else if teams_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
297 warn!("GitHub teams response too large — treating as empty");
298 Vec::new()
299 } else {
300 serde_json::from_slice(&teams_bytes).unwrap_or_else(|e| {
301 warn!(error = %e, "Failed to parse GitHub teams response — treating as empty");
302 Vec::new()
303 })
304 };
305
306 let team_strings: Vec<String> =
307 teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
308
309 let org_id = team_strings
311 .first()
312 .and_then(|team| team.split(':').next())
313 .map(|org| org.to_string());
314
315 let mut user_info = user_info;
317 user_info.raw_claims["github_id"] = serde_json::json!(github_user.id);
318 user_info.raw_claims["github_login"] = serde_json::json!(github_user.login);
319 user_info.raw_claims["github_teams"] = serde_json::json!(team_strings);
320 user_info.raw_claims["github_company"] = serde_json::json!(github_user.company);
321 user_info.raw_claims["github_location"] = serde_json::json!(github_user.location);
322 user_info.raw_claims["github_public_repos"] = serde_json::json!(github_user.public_repos);
323
324 if let Some(org_id) = org_id {
326 user_info.raw_claims["org_id"] = serde_json::json!(&org_id);
327 }
328
329 Ok(user_info)
330 }
331
332 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
333 self.oidc.refresh_token(refresh_token).await
334 }
335
336 async fn revoke_token(&self, token: &str) -> Result<()> {
337 self.oidc.revoke_token(token).await
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 #[allow(clippy::wildcard_imports)]
344 use super::*;
346
347 #[test]
348 fn test_map_github_teams_to_roles() {
349 let teams = vec![
350 "acme-corp:admin".to_string(),
351 "acme-corp:operators".to_string(),
352 "acme-corp:unknown".to_string(),
353 "other-org:viewer".to_string(),
354 ];
355
356 let roles = GitHubOAuth::map_teams_to_roles(teams);
357
358 assert_eq!(roles.len(), 3);
359 assert!(roles.contains(&"admin".to_string()));
360 assert!(roles.contains(&"operator".to_string()));
361 assert!(roles.contains(&"viewer".to_string()));
362 }
363
364 #[test]
365 fn test_map_teams_empty() {
366 let roles = GitHubOAuth::map_teams_to_roles(vec![]);
367 assert!(roles.is_empty());
368 }
369
370 #[test]
371 fn test_map_teams_no_matches() {
372 let teams = vec!["org:unknown-team".to_string(), "org:other".to_string()];
373 let roles = GitHubOAuth::map_teams_to_roles(teams);
374 assert!(roles.is_empty());
375 }
376
377 #[test]
380 fn github_response_cap_constant_is_reasonable() {
381 const { assert!(MAX_GITHUB_RESPONSE_BYTES >= 1024 * 1024) }
382 const { assert!(MAX_GITHUB_RESPONSE_BYTES <= 100 * 1024 * 1024) }
383 }
384
385 #[test]
386 fn github_request_timeout_is_set() {
387 let secs = GITHUB_REQUEST_TIMEOUT.as_secs();
389 assert!(secs > 0 && secs <= 120, "GitHub timeout should be 1–120 s, got {secs}");
390 }
391}