Skip to main content

authx_plugins/oauth/providers/
github.rs

1use async_trait::async_trait;
2use tracing::instrument;
3
4use authx_core::error::{AuthError, Result};
5
6use super::{OAuthProvider, OAuthTokens, OAuthUserInfo};
7
8pub struct GitHubProvider {
9    client_id: String,
10    client_secret: String,
11    http: reqwest::Client,
12}
13
14impl GitHubProvider {
15    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
16        Self {
17            client_id: client_id.into(),
18            client_secret: client_secret.into(),
19            http: reqwest::Client::new(),
20        }
21    }
22}
23
24#[async_trait]
25impl OAuthProvider for GitHubProvider {
26    fn name(&self) -> &'static str {
27        "github"
28    }
29
30    fn authorization_url(&self, state: &str, pkce_challenge: &str) -> String {
31        format!(
32            "https://github.com/login/oauth/authorize\
33             ?client_id={}\
34             &scope=read%3Auser%20user%3Aemail\
35             &state={}\
36             &code_challenge={}\
37             &code_challenge_method=S256",
38            urlencoding::encode(&self.client_id),
39            urlencoding::encode(state),
40            urlencoding::encode(pkce_challenge),
41        )
42    }
43
44    #[instrument(skip(self, code, pkce_verifier))]
45    async fn exchange_code(
46        &self,
47        code: &str,
48        pkce_verifier: &str,
49        redirect_uri: &str,
50    ) -> Result<OAuthTokens> {
51        let res = self
52            .http
53            .post("https://github.com/login/oauth/access_token")
54            .header("Accept", "application/json")
55            .form(&[
56                ("code", code),
57                ("client_id", &self.client_id),
58                ("client_secret", &self.client_secret),
59                ("redirect_uri", redirect_uri),
60                ("code_verifier", pkce_verifier),
61            ])
62            .send()
63            .await
64            .map_err(|e| AuthError::Internal(format!("github token request failed: {e}")))?;
65
66        if !res.status().is_success() {
67            let body = res.text().await.unwrap_or_default();
68            return Err(AuthError::Internal(format!(
69                "github token exchange error: {body}"
70            )));
71        }
72
73        let json: serde_json::Value = res
74            .json()
75            .await
76            .map_err(|e| AuthError::Internal(format!("github token json: {e}")))?;
77
78        if let Some(err) = json["error"].as_str() {
79            return Err(AuthError::Internal(format!("github oauth error: {err}")));
80        }
81
82        tracing::debug!("github token exchange succeeded");
83        Ok(OAuthTokens {
84            access_token: json["access_token"].as_str().unwrap_or("").to_owned(),
85            refresh_token: json["refresh_token"].as_str().map(ToOwned::to_owned),
86            expires_in: json["expires_in"].as_u64(),
87        })
88    }
89
90    #[instrument(skip(self, access_token))]
91    async fn fetch_user_info(&self, access_token: &str) -> Result<OAuthUserInfo> {
92        // Primary user endpoint.
93        let user_res = self
94            .http
95            .get("https://api.github.com/user")
96            .bearer_auth(access_token)
97            .header("User-Agent", "authx-rs")
98            .send()
99            .await
100            .map_err(|e| AuthError::Internal(format!("github user request failed: {e}")))?;
101
102        if !user_res.status().is_success() {
103            return Err(AuthError::Internal("github user fetch error".into()));
104        }
105        let user_json: serde_json::Value = user_res
106            .json()
107            .await
108            .map_err(|e| AuthError::Internal(format!("github user json: {e}")))?;
109
110        let id = user_json["id"].as_u64().unwrap_or(0).to_string();
111        let name = user_json["name"].as_str().map(ToOwned::to_owned);
112
113        // Fetch primary verified email.
114        let emails_res = self
115            .http
116            .get("https://api.github.com/user/emails")
117            .bearer_auth(access_token)
118            .header("User-Agent", "authx-rs")
119            .send()
120            .await
121            .map_err(|e| AuthError::Internal(format!("github emails request failed: {e}")))?;
122
123        let emails: Vec<serde_json::Value> = emails_res
124            .json()
125            .await
126            .map_err(|e| AuthError::Internal(format!("github emails json: {e}")))?;
127
128        let email = emails
129            .iter()
130            .find(|e| {
131                e["primary"].as_bool().unwrap_or(false) && e["verified"].as_bool().unwrap_or(false)
132            })
133            .and_then(|e| e["email"].as_str())
134            .or_else(|| user_json["email"].as_str())
135            .ok_or_else(|| AuthError::Internal("github: no verified email found".into()))?
136            .to_owned();
137
138        tracing::debug!(provider = "github", "user info fetched");
139        Ok(OAuthUserInfo {
140            provider_user_id: id,
141            email,
142            name,
143        })
144    }
145}