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