Skip to main content

alibi_api/plugins/oauth/providers/
github.rs

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