use std::{fmt::Write as _, time::Duration};
use async_trait::async_trait;
use serde::Deserialize;
use tracing::warn;
use zeroize::Zeroizing;
pub(crate) const GITHUB_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const MAX_GITHUB_RESPONSE_BYTES: usize = 5 * 1024 * 1024;
const DEFAULT_BASE_URL: &str = "https://github.com";
const DEFAULT_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_SCOPES: &str = "read:user user:email";
use crate::{
error::{AuthError, Result},
oidc_provider::validate_oauth_endpoint_url,
provider::{OAuthProvider, TokenResponse, UserInfo},
};
pub struct GitHubOAuth {
client_id: String,
client_secret: Zeroizing<String>,
redirect_uri: String,
base_url: String,
api_base_url: String,
client: reqwest::Client,
}
impl std::fmt::Debug for GitHubOAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHubOAuth")
.field("client_id", &self.client_id)
.field("redirect_uri", &self.redirect_uri)
.field("base_url", &self.base_url)
.field("api_base_url", &self.api_base_url)
.finish_non_exhaustive() }
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubUser {
pub id: u64,
pub login: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub company: Option<String>,
pub location: Option<String>,
#[serde(default)]
pub public_repos: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct GitHubEmail {
pub email: String,
pub primary: bool,
pub verified: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubTeam {
pub id: u64,
pub name: String,
pub slug: String,
pub organization: GitHubOrg,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubOrg {
pub id: u64,
pub login: String,
}
#[derive(Debug, Deserialize)]
struct GitHubTokenResponse {
access_token: String,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
#[serde(default)]
refresh_token: Option<String>,
}
#[must_use]
pub fn select_linkable_email(emails: &[GitHubEmail]) -> Option<(String, bool)> {
emails.iter().find(|e| e.primary).map(|e| (e.email.clone(), e.verified))
}
impl GitHubOAuth {
pub fn new(client_id: String, client_secret: String, redirect_uri: String) -> Result<Self> {
Self::with_endpoints(
client_id,
client_secret,
redirect_uri,
DEFAULT_BASE_URL.to_string(),
DEFAULT_API_BASE_URL.to_string(),
)
}
pub fn with_endpoints(
client_id: String,
client_secret: String,
redirect_uri: String,
base_url: String,
api_base_url: String,
) -> Result<Self> {
validate_oauth_endpoint_url(&base_url)?;
validate_oauth_endpoint_url(&api_base_url)?;
let client =
reqwest::Client::builder()
.timeout(GITHUB_REQUEST_TIMEOUT)
.build()
.map_err(|e| AuthError::ConfigError {
message: format!("Failed to create HTTP client: {e}"),
})?;
Ok(Self {
client_id,
client_secret: Zeroizing::new(client_secret),
redirect_uri,
base_url: base_url.trim_end_matches('/').to_string(),
api_base_url: api_base_url.trim_end_matches('/').to_string(),
client,
})
}
#[must_use]
pub fn map_teams_to_roles(teams: Vec<String>) -> Vec<String> {
teams
.into_iter()
.filter_map(|team| {
let parts: Vec<&str> = team.split(':').collect();
if parts.len() == 2 {
match parts[1] {
"admin" | "administrators" | "admin-team" => Some("admin".to_string()),
"operator" | "operators" | "operator-team" | "maintainer"
| "maintainers" => Some("operator".to_string()),
"viewer" | "viewers" | "viewer-team" => Some("viewer".to_string()),
_ => None,
}
} else {
None
}
})
.collect()
}
async fn api_get(&self, path: &str, access_token: &str) -> Result<Vec<u8>> {
let resp = self
.client
.get(format!("{}{path}", self.api_base_url))
.header("Authorization", format!("Bearer {access_token}"))
.header("User-Agent", "FraiseQL")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| AuthError::OAuthError {
message: format!("Failed to fetch GitHub {path}: {e}"),
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| AuthError::OAuthError {
message: format!("Failed to read GitHub {path} response: {e}"),
})?;
if !status.is_success() {
return Err(AuthError::OAuthError {
message: format!("GitHub {path} API returned HTTP {status}"),
});
}
if bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
return Err(AuthError::OAuthError {
message: format!("GitHub {path} response too large ({} bytes)", bytes.len()),
});
}
Ok(bytes.to_vec())
}
async fn fetch_team_strings(&self, access_token: &str) -> Vec<String> {
let teams: Vec<GitHubTeam> = match self.api_get("/user/teams", access_token).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
warn!(error = %e, "Failed to parse GitHub teams response — treating as empty");
Vec::new()
}),
Err(e) => {
warn!(error = %e, "GitHub teams fetch failed — treating as empty");
Vec::new()
},
};
teams.iter().map(|t| format!("{}:{}", t.organization.login, t.slug)).collect()
}
pub async fn get_user_with_teams(
&self,
access_token: &str,
) -> Result<(GitHubUser, Vec<String>)> {
let user_bytes = self.api_get("/user", access_token).await?;
let user: GitHubUser =
serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse GitHub user: {e}"),
})?;
let team_strings = self.fetch_team_strings(access_token).await;
Ok((user, team_strings))
}
#[must_use]
pub fn extract_org_id_from_teams(teams: &[(GitHubUser, Vec<String>)]) -> Option<String> {
teams
.first()
.and_then(|(_, team_strings)| team_strings.first())
.and_then(|team_str| team_str.split(':').next())
.map(|org| org.to_string())
}
}
#[async_trait]
impl OAuthProvider for GitHubOAuth {
fn name(&self) -> &'static str {
"github"
}
fn authorization_url(&self, state: &str) -> String {
let mut url = format!("{}/login/oauth/authorize", self.base_url);
write!(
url,
"?client_id={}&redirect_uri={}&state={}&scope={}",
urlencoding::encode(&self.client_id),
urlencoding::encode(&self.redirect_uri),
urlencoding::encode(state),
urlencoding::encode(GITHUB_SCOPES),
)
.expect("write to String is infallible");
url
}
async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
let params = [
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
("code", code),
("redirect_uri", self.redirect_uri.as_str()),
];
let resp = self
.client
.post(format!("{}/login/oauth/access_token", self.base_url))
.header("Accept", "application/json")
.form(¶ms)
.send()
.await
.map_err(|e| AuthError::OAuthError {
message: format!("Failed to exchange code: {e}"),
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| AuthError::OAuthError {
message: format!("Failed to read token response: {e}"),
})?;
if !status.is_success() {
return Err(AuthError::OAuthError {
message: format!("GitHub token endpoint returned HTTP {status}"),
});
}
if bytes.len() > MAX_GITHUB_RESPONSE_BYTES {
return Err(AuthError::OAuthError {
message: format!("GitHub token response too large ({} bytes)", bytes.len()),
});
}
let response: GitHubTokenResponse =
serde_json::from_slice(&bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse GitHub token response: {e}"),
})?;
Ok(TokenResponse {
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in.unwrap_or(0),
token_type: response.token_type.unwrap_or_else(|| "bearer".to_string()),
id_token: None,
})
}
async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
let user_bytes = self.api_get("/user", access_token).await?;
let github_user: GitHubUser =
serde_json::from_slice(&user_bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse GitHub user: {e}"),
})?;
let (email, email_verified) = match self.api_get("/user/emails", access_token).await {
Ok(bytes) => match serde_json::from_slice::<Vec<GitHubEmail>>(&bytes) {
Ok(emails) => select_linkable_email(&emails)
.map_or_else(|| (github_user.email.clone(), false), |(e, v)| (Some(e), v)),
Err(e) => {
warn!(error = %e, "Failed to parse GitHub /user/emails — treating email as unverified");
(github_user.email.clone(), false)
},
},
Err(e) => {
warn!(error = %e, "GitHub /user/emails fetch failed — treating email as unverified");
(github_user.email.clone(), false)
},
};
let team_strings = self.fetch_team_strings(access_token).await;
let org_id = team_strings
.first()
.and_then(|team| team.split(':').next())
.map(|org| org.to_string());
let mut raw_claims = serde_json::Map::new();
raw_claims.insert("github_id".to_string(), serde_json::json!(github_user.id));
raw_claims.insert("github_login".to_string(), serde_json::json!(github_user.login));
raw_claims.insert("github_teams".to_string(), serde_json::json!(team_strings));
raw_claims.insert("github_company".to_string(), serde_json::json!(github_user.company));
raw_claims.insert("github_location".to_string(), serde_json::json!(github_user.location));
raw_claims
.insert("github_public_repos".to_string(), serde_json::json!(github_user.public_repos));
if let Some(ref email) = email {
raw_claims.insert("email".to_string(), serde_json::json!(email));
}
raw_claims.insert("email_verified".to_string(), serde_json::json!(email_verified));
if let Some(org_id) = org_id {
raw_claims.insert("org_id".to_string(), serde_json::json!(&org_id));
}
Ok(UserInfo {
id: github_user.id.to_string(),
email: email.filter(|e| !e.trim().is_empty()),
email_verified,
name: github_user.name.clone().or(Some(github_user.login.clone())),
picture: github_user.avatar_url.clone(),
raw_claims: serde_json::Value::Object(raw_claims),
})
}
async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
let params = [
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
];
let resp = self
.client
.post(format!("{}/login/oauth/access_token", self.base_url))
.header("Accept", "application/json")
.form(¶ms)
.send()
.await
.map_err(|e| AuthError::OAuthError {
message: format!("Failed to refresh token: {e}"),
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| AuthError::OAuthError {
message: format!("Failed to read refresh response: {e}"),
})?;
if !status.is_success() {
return Err(AuthError::OAuthError {
message: format!("GitHub refresh endpoint returned HTTP {status}"),
});
}
let response: GitHubTokenResponse =
serde_json::from_slice(&bytes).map_err(|e| AuthError::OAuthError {
message: format!("Failed to parse GitHub refresh response: {e}"),
})?;
Ok(TokenResponse {
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in.unwrap_or(0),
token_type: response.token_type.unwrap_or_else(|| "bearer".to_string()),
id_token: None,
})
}
}