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.
9const 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.
16const 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    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    /// Get user info including teams from GitHub API
133    ///
134    /// # Arguments
135    /// * `access_token` - GitHub access token
136    ///
137    /// # Errors
138    ///
139    /// Returns `AuthError::OAuthError` if the GitHub API request fails or returns
140    /// a non-success status code.
141    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        // Get user info
151        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        // Get teams (organizations membership)
180        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    /// Extract organization ID from GitHub teams (primary org)
213    ///
214    /// Returns the first organization the user belongs to as the org_id.
215    /// In multi-org scenarios, this should be overridden with explicit org selection.
216    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// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
226// its transformed method signatures to satisfy the trait contract
227// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
228#[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        // Get basic user info from OIDC
244        let user_info = self.oidc.user_info(access_token).await?;
245
246        // Fetch additional GitHub-specific data
247        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        // Get teams
280        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        // Extract org_id from primary organization
310        let org_id = team_strings
311            .first()
312            .and_then(|team| team.split(':').next())
313            .map(|org| org.to_string());
314
315        // Merge GitHub data into user info
316        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        // Add org_id if available (from primary organization)
325        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    // Reason: test module — wildcard keeps test boilerplate minimal
345    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    // ── S23-H3: GitHub API response size caps ─────────────────────────────────
378
379    #[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        // Verify the timeout is non-zero (non-const value, evaluated at runtime).
388        let secs = GITHUB_REQUEST_TIMEOUT.as_secs();
389        assert!(secs > 0 && secs <= 120, "GitHub timeout should be 1–120 s, got {secs}");
390    }
391}