fraiseql_server/auth/providers/
github.rs1use 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#[derive(Debug)]
15pub struct GitHubOAuth {
16 oidc: OidcProvider,
17}
18
19#[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#[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#[derive(Debug, Clone, Deserialize)]
44pub struct GitHubOrg {
45 pub id: u64,
46 pub login: String,
47}
48
49impl GitHubOAuth {
50 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 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 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 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 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 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 let user_info = self.oidc.user_info(access_token).await?;
176
177 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 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 let org_id = team_strings
213 .first()
214 .and_then(|team| team.split(':').next())
215 .map(|org| org.to_string());
216
217 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 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}