Skip to main content

alibi_plugins/oauth/providers/
github.rs

1use 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    /// Construct a GitHub provider using custom endpoints.
20    ///
21    /// This keeps the built-in GitHub semantics while allowing local test
22    /// harnesses or GitHub Enterprise-style deployments to override the URLs.
23    #[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}