1use std::time::Duration;
3
4use async_trait::async_trait;
5use serde::Deserialize;
6use tracing::warn;
7
8pub(crate) const GITHUB_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
10
11pub(crate) const 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 #[must_use]
113 pub fn map_teams_to_roles(teams: Vec<String>) -> Vec<String> {
114 teams
115 .into_iter()
116 .filter_map(|team| {
117 let parts: Vec<&str> = team.split(':').collect();
118 if parts.len() == 2 {
119 match parts[1] {
120 "admin" | "administrators" | "admin-team" => Some("admin".to_string()),
121 "operator" | "operators" | "operator-team" | "maintainer"
122 | "maintainers" => Some("operator".to_string()),
123 "viewer" | "viewers" | "viewer-team" => Some("viewer".to_string()),
124 _ => None,
125 }
126 } else {
127 None
128 }
129 })
130 .collect()
131 }
132
133 pub async fn get_user_with_teams(
143 &self,
144 access_token: &str,
145 ) -> Result<(GitHubUser, Vec<String>)> {
146 let client = reqwest::Client::builder()
147 .timeout(GITHUB_REQUEST_TIMEOUT)
148 .build()
149 .unwrap_or_default();
150
151 let user_resp = client
153 .get("https://api.github.com/user")
154 .header("Authorization", format!("token {}", access_token))
155 .header("User-Agent", "FraiseQL")
156 .send()
157 .await
158 .map_err(|e| AuthError::OAuthError {
159 message: format!("Failed to fetch GitHub user: {}", e),
160 })?;
161 let user_status = user_resp.status();
162 let user_bytes = user_resp.bytes().await.map_err(|e| AuthError::OAuthError {
163 message: format!("Failed to read GitHub user response: {}", e),
164 })?;
165 if !user_status.is_success() {
166 return Err(AuthError::OAuthError {
167 message: format!("GitHub user API returned HTTP {user_status}"),
168 });
169 }
170 if user_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
171 return Err(AuthError::OAuthError {
172 message: format!("GitHub user response too large ({} bytes)", user_bytes.len()),
173 });
174 }
175 let user: GitHubUser =
176 serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
177 message: format!("Failed to parse GitHub user: {}", e),
178 })?;
179
180 let teams_resp = client
182 .get("https://api.github.com/user/teams")
183 .header("Authorization", format!("token {}", access_token))
184 .header("User-Agent", "FraiseQL")
185 .send()
186 .await
187 .map_err(|e| AuthError::OAuthError {
188 message: format!("Failed to fetch GitHub teams: {}", e),
189 })?;
190 let teams_status = teams_resp.status();
191 let teams_bytes = teams_resp.bytes().await.map_err(|e| AuthError::OAuthError {
192 message: format!("Failed to read GitHub teams response: {}", e),
193 })?;
194 let teams: Vec<GitHubTeam> = if !teams_status.is_success() {
195 warn!(status = %teams_status, "GitHub teams API returned non-success — treating as empty");
196 Vec::new()
197 } else if teams_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
198 warn!("GitHub teams response too large — treating as empty");
199 Vec::new()
200 } else {
201 serde_json::from_slice(&teams_bytes).unwrap_or_else(|e| {
202 warn!(error = %e, "Failed to parse GitHub teams response — treating as empty");
203 Vec::new()
204 })
205 };
206
207 let team_strings: Vec<String> =
208 teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
209
210 Ok((user, team_strings))
211 }
212
213 #[must_use]
218 pub fn extract_org_id_from_teams(teams: &[(GitHubUser, Vec<String>)]) -> Option<String> {
219 teams
220 .first()
221 .and_then(|(_, team_strings)| team_strings.first())
222 .and_then(|team_str| team_str.split(':').next())
223 .map(|org| org.to_string())
224 }
225}
226
227#[async_trait]
231impl OAuthProvider for GitHubOAuth {
232 fn name(&self) -> &'static str {
233 "github"
234 }
235
236 fn authorization_url(&self, state: &str) -> String {
237 self.oidc.authorization_url(state)
238 }
239
240 async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
241 self.oidc.exchange_code(code).await
242 }
243
244 async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
245 let user_info = self.oidc.user_info(access_token).await?;
247
248 let client = reqwest::Client::builder()
250 .timeout(GITHUB_REQUEST_TIMEOUT)
251 .build()
252 .unwrap_or_default();
253 let user_resp = client
254 .get("https://api.github.com/user")
255 .header("Authorization", format!("token {}", access_token))
256 .header("User-Agent", "FraiseQL")
257 .send()
258 .await
259 .map_err(|e| AuthError::OAuthError {
260 message: format!("Failed to fetch GitHub user: {}", e),
261 })?;
262 let user_status = user_resp.status();
263 let user_bytes = user_resp.bytes().await.map_err(|e| AuthError::OAuthError {
264 message: format!("Failed to read GitHub user response: {}", e),
265 })?;
266 if !user_status.is_success() {
267 return Err(AuthError::OAuthError {
268 message: format!("GitHub user API returned HTTP {user_status}"),
269 });
270 }
271 if user_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
272 return Err(AuthError::OAuthError {
273 message: format!("GitHub user response too large ({} bytes)", user_bytes.len()),
274 });
275 }
276 let github_user: GitHubUser =
277 serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
278 message: format!("Failed to parse GitHub user: {}", e),
279 })?;
280
281 let teams_resp = client
283 .get("https://api.github.com/user/teams")
284 .header("Authorization", format!("token {}", access_token))
285 .header("User-Agent", "FraiseQL")
286 .send()
287 .await
288 .map_err(|e| AuthError::OAuthError {
289 message: format!("Failed to fetch GitHub teams: {}", e),
290 })?;
291 let teams_status = teams_resp.status();
292 let teams_bytes = teams_resp.bytes().await.map_err(|e| AuthError::OAuthError {
293 message: format!("Failed to read GitHub teams response: {}", e),
294 })?;
295 let teams: Vec<GitHubTeam> = if !teams_status.is_success() {
296 warn!(status = %teams_status, "GitHub teams API returned non-success — treating as empty");
297 Vec::new()
298 } else if teams_bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
299 warn!("GitHub teams response too large — treating as empty");
300 Vec::new()
301 } else {
302 serde_json::from_slice(&teams_bytes).unwrap_or_else(|e| {
303 warn!(error = %e, "Failed to parse GitHub teams response — treating as empty");
304 Vec::new()
305 })
306 };
307
308 let team_strings: Vec<String> =
309 teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
310
311 let org_id = team_strings
313 .first()
314 .and_then(|team| team.split(':').next())
315 .map(|org| org.to_string());
316
317 let mut user_info = user_info;
319 user_info.raw_claims["github_id"] = serde_json::json!(github_user.id);
320 user_info.raw_claims["github_login"] = serde_json::json!(github_user.login);
321 user_info.raw_claims["github_teams"] = serde_json::json!(team_strings);
322 user_info.raw_claims["github_company"] = serde_json::json!(github_user.company);
323 user_info.raw_claims["github_location"] = serde_json::json!(github_user.location);
324 user_info.raw_claims["github_public_repos"] = serde_json::json!(github_user.public_repos);
325
326 if let Some(org_id) = org_id {
328 user_info.raw_claims["org_id"] = serde_json::json!(&org_id);
329 }
330
331 Ok(user_info)
332 }
333
334 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
335 self.oidc.refresh_token(refresh_token).await
336 }
337
338 async fn revoke_token(&self, token: &str) -> Result<()> {
339 self.oidc.revoke_token(token).await
340 }
341}