use super::{
Arc, Deserialize, DeserializeOwned, OAuthAuthorizationPolicy, OAuthProvider, OAuthUserInfo,
OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse, Value, async_trait,
};
use serde_json::Map;
impl OAuthProvider {
#[must_use]
pub fn github(client_id: &str, client_secret: &str) -> Self {
Self::github_with_endpoints(
client_id,
client_secret,
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
"https://api.github.com/user",
"https://api.github.com/user/emails",
)
}
#[must_use]
pub fn github_with_endpoints(
client_id: &str,
client_secret: &str,
auth_url: &str,
token_url: &str,
user_info_url: &str,
user_emails_url: &str,
) -> Self {
Self {
client_id: client_id.to_owned(),
additional_client_ids: Vec::new(),
hosted_domain: None,
require_email_verification: false,
client_secret: client_secret.to_owned(),
auth_url: auth_url.to_owned(),
token_url: token_url.to_owned(),
user_info_url: Some(user_info_url.to_owned()),
scopes: vec!["read:user".to_owned(), "user:email".to_owned()],
authorization: Some(OAuthAuthorizationPolicy::default()),
allowed_request_params: Vec::new(),
authorization_params: Vec::new(),
account_subject: None,
map_user_info: None,
get_user_info: Some(Arc::new(GitHubUserInfoHandler::new(
user_info_url.to_owned(),
user_emails_url.to_owned(),
))),
refresh_access_token: None,
verify_id_token: None,
id_token: None,
disable_id_token_sign_in: false,
disable_implicit_sign_up: false,
disable_sign_up: false,
allow_idp_initiated: false,
override_user_info_on_sign_in: false,
}
}
}
#[derive(Debug, Deserialize)]
pub(in crate::oauth::providers) struct GitHubEmailAddress {
pub(in crate::oauth::providers) email: String,
#[serde(default)]
pub(in crate::oauth::providers) primary: bool,
#[serde(default)]
pub(in crate::oauth::providers) verified: bool,
}
#[derive(Clone)]
pub(in crate::oauth::providers) struct GitHubUserInfoHandler {
pub(in crate::oauth::providers) user_url: String,
pub(in crate::oauth::providers) emails_url: String,
}
impl GitHubUserInfoHandler {
pub(in crate::oauth::providers) const fn new(user_url: String, emails_url: String) -> Self {
Self {
user_url,
emails_url,
}
}
pub(in crate::oauth::providers) async fn fetch_json<T: DeserializeOwned>(
&self,
client: &reqwest::Client,
url: &str,
access_token: &str,
) -> Result<T, String> {
let response = client
.get(url)
.bearer_auth(access_token)
.header("Accept", "application/json")
.header("User-Agent", "better-auth")
.send()
.await
.map_err(|error| format!("Failed to fetch GitHub user info: {error}"))?;
if !response.status().is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_owned());
return Err(format!("GitHub user info request failed: {body}"));
}
response
.json()
.await
.map_err(|error| format!("Failed to parse GitHub user info: {error}"))
}
}
#[async_trait]
impl OAuthUserInfoHandler for GitHubUserInfoHandler {
async fn get_user_info(
&self,
request: OAuthUserInfoRequest,
) -> Result<OAuthUserInfoResponse, String> {
let access_token = request
.access_token
.as_deref()
.ok_or("Missing access token for user-info lookup")?;
let client = reqwest::Client::new();
let mut profile: Value = self
.fetch_json(&client, &self.user_url, access_token)
.await?;
let emails = self
.fetch_json::<Vec<GitHubEmailAddress>>(&client, &self.emails_url, access_token)
.await
.unwrap_or_default();
let resolved_email = profile
.get("email")
.and_then(Value::as_str)
.filter(|email| !email.is_empty())
.map(String::from)
.or_else(|| {
emails
.iter()
.find(|record| record.primary)
.or_else(|| emails.first())
.map(|record| record.email.clone())
})
.unwrap_or_default();
if let Some(profile_object) = profile.as_object_mut()
&& profile_object
.get("email")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
&& !resolved_email.is_empty()
{
_ = profile_object.insert("email".to_owned(), Value::String(resolved_email.clone()));
}
let email_verified = emails
.iter()
.find(|record| record.email == resolved_email)
.is_some_and(|record| record.verified);
let id = profile
.get("id")
.and_then(|value| value.as_i64().map(|value| value.to_string()))
.or_else(|| profile.get("id").and_then(Value::as_str).map(String::from))
.ok_or("missing id")?;
let login = profile
.get("login")
.and_then(Value::as_str)
.map(String::from);
Ok(OAuthUserInfoResponse {
user_output: None,
user: OAuthUserInfo {
additional_fields: Map::default(),
id,
email: resolved_email,
name: Some(
profile
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
.map(String::from)
.or(login)
.unwrap_or_default(),
),
image: profile
.get("avatar_url")
.and_then(Value::as_str)
.map(String::from),
email_verified,
},
data: profile,
})
}
}