Skip to main content

fraiseql_auth/providers/
github.rs

1//! GitHub OAuth provider implementation (uses GitHub's non-OIDC OAuth 2.0 API).
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde::Deserialize;
6use tracing::warn;
7
8/// Timeout for all GitHub API HTTP requests.
9pub(crate) const GITHUB_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
10
11/// Maximum byte size for a GitHub API response.
12///
13/// GitHub user and team responses are small JSON documents (< 10 `KiB`).
14/// 5 `MiB` is a generous cap that blocks allocation bombs from network
15/// intermediaries while accommodating any legitimate response size.
16pub(crate) const MAX_GITHUB_RESPONSE_BYTES: usize = 5 * 1024 * 1024; // 5 MiB
17
18use crate::{
19    error::{AuthError, Result},
20    oidc_provider::OidcProvider,
21    provider::{OAuthProvider, TokenResponse, UserInfo},
22};
23
24/// GitHub OAuth provider wrapper
25///
26/// Handles GitHub-specific OAuth flows and team mapping to FraiseQL roles.
27#[derive(Debug)]
28pub struct GitHubOAuth {
29    oidc: OidcProvider,
30}
31
32/// GitHub user information with teams
33#[derive(Debug, Clone, Deserialize)]
34pub struct GitHubUser {
35    /// GitHub numeric user ID (stable across username changes)
36    pub id:           u64,
37    /// GitHub username (login handle)
38    pub login:        String,
39    /// Primary email address (may be `None` if the user keeps it private)
40    pub email:        Option<String>,
41    /// User's display name
42    pub name:         Option<String>,
43    /// URL to the user's avatar image
44    pub avatar_url:   Option<String>,
45    /// Short biography text from the profile
46    pub bio:          Option<String>,
47    /// Company name from the profile
48    pub company:      Option<String>,
49    /// Location from the profile
50    pub location:     Option<String>,
51    /// Number of public repositories owned by the user
52    pub public_repos: u32,
53}
54
55/// GitHub team from API response
56#[derive(Debug, Clone, Deserialize)]
57pub struct GitHubTeam {
58    /// GitHub numeric team ID
59    pub id:           u64,
60    /// Human-readable team name
61    pub name:         String,
62    /// URL-safe team slug (used in API paths)
63    pub slug:         String,
64    /// Organization that owns this team
65    pub organization: GitHubOrg,
66}
67
68/// GitHub organization
69#[derive(Debug, Clone, Deserialize)]
70pub struct GitHubOrg {
71    /// GitHub numeric organization ID
72    pub id:    u64,
73    /// Organization login (handle)
74    pub login: String,
75}
76
77impl GitHubOAuth {
78    /// Create a new GitHub OAuth provider
79    ///
80    /// # Arguments
81    /// * `client_id` - GitHub OAuth app client ID
82    /// * `client_secret` - GitHub OAuth app client secret
83    /// * `redirect_uri` - Redirect URI after authentication (e.g., "http://localhost:8000/auth/callback")
84    ///
85    /// # Errors
86    ///
87    /// Returns `AuthError` if OIDC discovery against GitHub fails.
88    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    /// Map GitHub teams to FraiseQL roles
106    ///
107    /// Maps organization:team slugs to role names.
108    /// Example: "my-org:admin-team" -> "admin"
109    ///
110    /// # Arguments
111    /// * `teams` - List of "org:team" strings from GitHub
112    #[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    /// Get user info including teams from GitHub API
134    ///
135    /// # Arguments
136    /// * `access_token` - GitHub access token
137    ///
138    /// # Errors
139    ///
140    /// Returns `AuthError::OAuthError` if the GitHub API request fails or returns
141    /// a non-success status code.
142    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        // Get user info
152        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        // Get teams (organizations membership)
181        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    /// Extract organization ID from GitHub teams (primary org)
214    ///
215    /// Returns the first organization the user belongs to as the org_id.
216    /// In multi-org scenarios, this should be overridden with explicit org selection.
217    #[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// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
228// its transformed method signatures to satisfy the trait contract
229// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
230#[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        // Get basic user info from OIDC
246        let user_info = self.oidc.user_info(access_token).await?;
247
248        // Fetch additional GitHub-specific data
249        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        // Get teams
282        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        // Extract org_id from primary organization
312        let org_id = team_strings
313            .first()
314            .and_then(|team| team.split(':').next())
315            .map(|org| org.to_string());
316
317        // Merge GitHub data into user info
318        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        // Add org_id if available (from primary organization)
327        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}