alibi_plugins/oauth/providers/
github.rs1use super::{
2 Arc, Deserialize, DeserializeOwned, OAuthAuthorizationPolicy, OAuthProvider, OAuthUserInfo,
3 OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse, Value, async_trait,
4};
5use serde_json::Map;
6impl OAuthProvider {
7 #[must_use]
8 pub fn github(client_id: &str, client_secret: &str) -> Self {
9 Self::github_with_endpoints(
10 client_id,
11 client_secret,
12 "https://github.com/login/oauth/authorize",
13 "https://github.com/login/oauth/access_token",
14 "https://api.github.com/user",
15 "https://api.github.com/user/emails",
16 )
17 }
18
19 #[must_use]
24 pub fn github_with_endpoints(
25 client_id: &str,
26 client_secret: &str,
27 auth_url: &str,
28 token_url: &str,
29 user_info_url: &str,
30 user_emails_url: &str,
31 ) -> Self {
32 Self {
33 client_id: client_id.to_owned(),
34 additional_client_ids: Vec::new(),
35 hosted_domain: None,
36 require_email_verification: false,
37 client_secret: client_secret.to_owned(),
38 auth_url: auth_url.to_owned(),
39 token_url: token_url.to_owned(),
40 user_info_url: Some(user_info_url.to_owned()),
41 scopes: vec!["read:user".to_owned(), "user:email".to_owned()],
42 authorization: Some(OAuthAuthorizationPolicy::default()),
43 authorization_params: Vec::new(),
44 account_subject: None,
45 map_user_info: None,
46 get_user_info: Some(Arc::new(GitHubUserInfoHandler::new(
47 user_info_url.to_owned(),
48 user_emails_url.to_owned(),
49 ))),
50 refresh_access_token: None,
51 verify_id_token: None,
52 id_token: None,
53 disable_id_token_sign_in: false,
54 disable_implicit_sign_up: false,
55 disable_sign_up: false,
56 allow_idp_initiated: false,
57 override_user_info_on_sign_in: false,
58 }
59 }
60}
61
62#[derive(Debug, Deserialize)]
63pub(in crate::oauth::providers) struct GitHubEmailAddress {
64 pub(in crate::oauth::providers) email: String,
65 #[serde(default)]
66 pub(in crate::oauth::providers) primary: bool,
67 #[serde(default)]
68 pub(in crate::oauth::providers) verified: bool,
69}
70
71#[derive(Clone)]
72pub(in crate::oauth::providers) struct GitHubUserInfoHandler {
73 pub(in crate::oauth::providers) user_url: String,
74 pub(in crate::oauth::providers) emails_url: String,
75}
76
77impl GitHubUserInfoHandler {
78 pub(in crate::oauth::providers) const fn new(user_url: String, emails_url: String) -> Self {
79 Self {
80 user_url,
81 emails_url,
82 }
83 }
84
85 pub(in crate::oauth::providers) async fn fetch_json<T: DeserializeOwned>(
86 &self,
87 client: &reqwest::Client,
88 url: &str,
89 access_token: &str,
90 ) -> Result<T, String> {
91 let response = client
92 .get(url)
93 .bearer_auth(access_token)
94 .header("Accept", "application/json")
95 .header("User-Agent", "better-auth")
96 .send()
97 .await
98 .map_err(|error| format!("Failed to fetch GitHub user info: {error}"))?;
99
100 if !response.status().is_success() {
101 let body = response
102 .text()
103 .await
104 .unwrap_or_else(|_| "Unknown error".to_owned());
105 return Err(format!("GitHub user info request failed: {body}"));
106 }
107
108 response
109 .json()
110 .await
111 .map_err(|error| format!("Failed to parse GitHub user info: {error}"))
112 }
113}
114
115#[async_trait]
116impl OAuthUserInfoHandler for GitHubUserInfoHandler {
117 async fn get_user_info(
118 &self,
119 request: OAuthUserInfoRequest,
120 ) -> Result<OAuthUserInfoResponse, String> {
121 let access_token = request
122 .access_token
123 .as_deref()
124 .ok_or("Missing access token for user-info lookup")?;
125
126 let client = reqwest::Client::new();
127 let mut profile: Value = self
128 .fetch_json(&client, &self.user_url, access_token)
129 .await?;
130 let emails = self
131 .fetch_json::<Vec<GitHubEmailAddress>>(&client, &self.emails_url, access_token)
132 .await
133 .unwrap_or_default();
134
135 let resolved_email = profile
136 .get("email")
137 .and_then(Value::as_str)
138 .filter(|email| !email.is_empty())
139 .map(String::from)
140 .or_else(|| {
141 emails
142 .iter()
143 .find(|record| record.primary)
144 .or_else(|| emails.first())
145 .map(|record| record.email.clone())
146 })
147 .unwrap_or_default();
148
149 if let Some(profile_object) = profile.as_object_mut()
150 && profile_object
151 .get("email")
152 .and_then(Value::as_str)
153 .is_none_or(str::is_empty)
154 && !resolved_email.is_empty()
155 {
156 _ = profile_object.insert("email".to_owned(), Value::String(resolved_email.clone()));
157 }
158
159 let email_verified = emails
160 .iter()
161 .find(|record| record.email == resolved_email)
162 .is_some_and(|record| record.verified);
163
164 let id = profile
165 .get("id")
166 .and_then(|value| value.as_i64().map(|value| value.to_string()))
167 .or_else(|| profile.get("id").and_then(Value::as_str).map(String::from))
168 .ok_or("missing id")?;
169
170 let login = profile
171 .get("login")
172 .and_then(Value::as_str)
173 .map(String::from);
174
175 Ok(OAuthUserInfoResponse {
176 user_output: None,
177 user: OAuthUserInfo {
178 additional_fields: Map::default(),
179 id,
180 email: resolved_email,
181 name: Some(
182 profile
183 .get("name")
184 .and_then(Value::as_str)
185 .filter(|name| !name.is_empty())
186 .map(String::from)
187 .or(login)
188 .unwrap_or_default(),
189 ),
190 image: profile
191 .get("avatar_url")
192 .and_then(Value::as_str)
193 .map(String::from),
194 email_verified,
195 },
196 data: profile,
197 })
198 }
199}