Skip to main content

fraiseql_server/auth/providers/
github.rs

1// GitHub OAuth provider implementation
2use async_trait::async_trait;
3use serde::Deserialize;
4
5use crate::auth::{
6    error::{AuthError, Result},
7    oidc_provider::OidcProvider,
8    provider::{OAuthProvider, TokenResponse, UserInfo},
9};
10
11/// GitHub OAuth provider wrapper
12///
13/// Handles GitHub-specific OAuth flows and team mapping to FraiseQL roles.
14#[derive(Debug)]
15pub struct GitHubOAuth {
16    oidc: OidcProvider,
17}
18
19/// GitHub user information with teams
20#[derive(Debug, Clone, Deserialize)]
21pub struct GitHubUser {
22    pub id:           u64,
23    pub login:        String,
24    pub email:        Option<String>,
25    pub name:         Option<String>,
26    pub avatar_url:   Option<String>,
27    pub bio:          Option<String>,
28    pub company:      Option<String>,
29    pub location:     Option<String>,
30    pub public_repos: u32,
31}
32
33/// GitHub team from API response
34#[derive(Debug, Clone, Deserialize)]
35pub struct GitHubTeam {
36    pub id:           u64,
37    pub name:         String,
38    pub slug:         String,
39    pub organization: GitHubOrg,
40}
41
42/// GitHub organization
43#[derive(Debug, Clone, Deserialize)]
44pub struct GitHubOrg {
45    pub id:    u64,
46    pub login: String,
47}
48
49impl GitHubOAuth {
50    /// Create a new GitHub OAuth provider
51    ///
52    /// # Arguments
53    /// * `client_id` - GitHub OAuth app client ID
54    /// * `client_secret` - GitHub OAuth app client secret
55    /// * `redirect_uri` - Redirect URI after authentication (e.g., "http://localhost:8000/auth/callback")
56    pub async fn new(
57        client_id: String,
58        client_secret: String,
59        redirect_uri: String,
60    ) -> Result<Self> {
61        let oidc = OidcProvider::new(
62            "github",
63            "https://github.com",
64            &client_id,
65            &client_secret,
66            &redirect_uri,
67        )
68        .await?;
69
70        Ok(Self { oidc })
71    }
72
73    /// Map GitHub teams to FraiseQL roles
74    ///
75    /// Maps organization:team slugs to role names.
76    /// Example: "my-org:admin-team" -> "admin"
77    ///
78    /// # Arguments
79    /// * `teams` - List of "org:team" strings from GitHub
80    pub fn map_teams_to_roles(teams: Vec<String>) -> Vec<String> {
81        teams
82            .into_iter()
83            .filter_map(|team| {
84                let parts: Vec<&str> = team.split(':').collect();
85                if parts.len() == 2 {
86                    match parts[1] {
87                        "admin" | "administrators" | "admin-team" => Some("admin".to_string()),
88                        "operator" | "operators" | "operator-team" => Some("operator".to_string()),
89                        "viewer" | "viewers" | "viewer-team" => Some("viewer".to_string()),
90                        "maintainer" | "maintainers" => Some("operator".to_string()),
91                        _ => None,
92                    }
93                } else {
94                    None
95                }
96            })
97            .collect()
98    }
99
100    /// Get user info including teams from GitHub API
101    ///
102    /// # Arguments
103    /// * `access_token` - GitHub access token
104    pub async fn get_user_with_teams(
105        &self,
106        access_token: &str,
107    ) -> Result<(GitHubUser, Vec<String>)> {
108        let client = reqwest::Client::new();
109
110        // Get user info
111        let user: GitHubUser = client
112            .get("https://api.github.com/user")
113            .header("Authorization", format!("token {}", access_token))
114            .header("User-Agent", "FraiseQL")
115            .send()
116            .await
117            .map_err(|e| AuthError::OAuthError {
118                message: format!("Failed to fetch GitHub user: {}", e),
119            })?
120            .json()
121            .await
122            .map_err(|e| AuthError::OAuthError {
123                message: format!("Failed to parse GitHub user: {}", e),
124            })?;
125
126        // Get teams (organizations membership)
127        let teams: Vec<GitHubTeam> = client
128            .get("https://api.github.com/user/teams")
129            .header("Authorization", format!("token {}", access_token))
130            .header("User-Agent", "FraiseQL")
131            .send()
132            .await
133            .map_err(|e| AuthError::OAuthError {
134                message: format!("Failed to fetch GitHub teams: {}", e),
135            })?
136            .json()
137            .await
138            .unwrap_or_default();
139
140        let team_strings: Vec<String> =
141            teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
142
143        Ok((user, team_strings))
144    }
145
146    /// Extract organization ID from GitHub teams (primary org)
147    ///
148    /// Returns the first organization the user belongs to as the org_id.
149    /// In multi-org scenarios, this should be overridden with explicit org selection.
150    pub fn extract_org_id_from_teams(teams: &[(GitHubUser, Vec<String>)]) -> Option<String> {
151        teams
152            .first()
153            .and_then(|(_, team_strings)| team_strings.first())
154            .and_then(|team_str| team_str.split(':').next())
155            .map(|org| org.to_string())
156    }
157}
158
159#[async_trait]
160impl OAuthProvider for GitHubOAuth {
161    fn name(&self) -> &'static str {
162        "github"
163    }
164
165    fn authorization_url(&self, state: &str) -> String {
166        self.oidc.authorization_url(state)
167    }
168
169    async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
170        self.oidc.exchange_code(code).await
171    }
172
173    async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
174        // Get basic user info from OIDC
175        let user_info = self.oidc.user_info(access_token).await?;
176
177        // Fetch additional GitHub-specific data
178        let client = reqwest::Client::new();
179        let github_user: GitHubUser = client
180            .get("https://api.github.com/user")
181            .header("Authorization", format!("token {}", access_token))
182            .header("User-Agent", "FraiseQL")
183            .send()
184            .await
185            .map_err(|e| AuthError::OAuthError {
186                message: format!("Failed to fetch GitHub user: {}", e),
187            })?
188            .json()
189            .await
190            .map_err(|e| AuthError::OAuthError {
191                message: format!("Failed to parse GitHub user: {}", e),
192            })?;
193
194        // Get teams
195        let teams: Vec<GitHubTeam> = client
196            .get("https://api.github.com/user/teams")
197            .header("Authorization", format!("token {}", access_token))
198            .header("User-Agent", "FraiseQL")
199            .send()
200            .await
201            .map_err(|e| AuthError::OAuthError {
202                message: format!("Failed to fetch GitHub teams: {}", e),
203            })?
204            .json()
205            .await
206            .unwrap_or_default();
207
208        let team_strings: Vec<String> =
209            teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect();
210
211        // Extract org_id from primary organization
212        let org_id = team_strings
213            .first()
214            .and_then(|team| team.split(':').next())
215            .map(|org| org.to_string());
216
217        // Merge GitHub data into user info
218        let mut user_info = user_info;
219        user_info.raw_claims["github_id"] = serde_json::json!(github_user.id);
220        user_info.raw_claims["github_login"] = serde_json::json!(github_user.login);
221        user_info.raw_claims["github_teams"] = serde_json::json!(team_strings);
222        user_info.raw_claims["github_company"] = serde_json::json!(github_user.company);
223        user_info.raw_claims["github_location"] = serde_json::json!(github_user.location);
224        user_info.raw_claims["github_public_repos"] = serde_json::json!(github_user.public_repos);
225
226        // Add org_id if available (from primary organization)
227        if let Some(org_id) = org_id {
228            user_info.raw_claims["org_id"] = serde_json::json!(&org_id);
229        }
230
231        Ok(user_info)
232    }
233
234    async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
235        self.oidc.refresh_token(refresh_token).await
236    }
237
238    async fn revoke_token(&self, token: &str) -> Result<()> {
239        self.oidc.revoke_token(token).await
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_map_github_teams_to_roles() {
249        let teams = vec![
250            "acme-corp:admin".to_string(),
251            "acme-corp:operators".to_string(),
252            "acme-corp:unknown".to_string(),
253            "other-org:viewer".to_string(),
254        ];
255
256        let roles = GitHubOAuth::map_teams_to_roles(teams);
257
258        assert_eq!(roles.len(), 3);
259        assert!(roles.contains(&"admin".to_string()));
260        assert!(roles.contains(&"operator".to_string()));
261        assert!(roles.contains(&"viewer".to_string()));
262    }
263
264    #[test]
265    fn test_map_teams_empty() {
266        let roles = GitHubOAuth::map_teams_to_roles(vec![]);
267        assert!(roles.is_empty());
268    }
269
270    #[test]
271    fn test_map_teams_no_matches() {
272        let teams = vec!["org:unknown-team".to_string(), "org:other".to_string()];
273        let roles = GitHubOAuth::map_teams_to_roles(teams);
274        assert!(roles.is_empty());
275    }
276}